Skip to content

Commit 2122c39

Browse files
authored
Add TextEnvelope class for saving and loading Witness and Transaction files (#448)
* test: add cardano-cli tests for latest * test: add fixtures for transaction failure scenarios in cardano-cli tests * feat: add TextEnvelope class for JSON serialization and deserialization * test: add tests for network magic and DRep serialization and TextEnvelope save and load * test: add tests for TextEnvelope and witness * test: add unit tests for BlockFrostChainContext methods to improve coverage * lint: clean up imports * fix: remove private properties from repr * fix: update vkey handling and improve repr * test: add repr call in Transaction test for verification * style: correct typos in function docstrings * refactor: add JSON save and load methods to CBORSerializable * refactor: override add save and load methods for Address object * refactor: remove duplicate save and load methods from Key class * refactor: revert to previous version and add properties for to_json * test: add save and load tests for Address and CBORSerializable classes * fix: add indentation to JSON serialization output * fix: add to_shallow_primitive method to display witness properly * fix: update serialization to use class docstring or default string
1 parent 42c9044 commit 2122c39

13 files changed

+758
-19
lines changed

pycardano/address.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88

99
from __future__ import annotations
1010

11+
import os
1112
from enum import Enum
12-
from typing import Type, Union
13+
from typing import Optional, Type, Union
14+
15+
from typing_extensions import override
1316

1417
from pycardano.crypto.bech32 import decode, encode
1518
from pycardano.exception import (
@@ -406,3 +409,43 @@ def __eq__(self, other):
406409

407410
def __repr__(self):
408411
return f"{self.encode()}"
412+
413+
@override
414+
def save(
415+
self,
416+
path: str,
417+
key_type: Optional[str] = None,
418+
description: Optional[str] = None,
419+
):
420+
"""
421+
Save the Address object to a file.
422+
423+
This method writes the object's JSON representation to the specified file path.
424+
It raises an error if the file already exists and is not empty.
425+
426+
Args:
427+
path (str): The file path to save the object to.
428+
key_type (str, optional): Not used in this context, but can be included for consistency.
429+
description (str, optional): Not used in this context, but can be included for consistency.
430+
431+
Raises:
432+
IOError: If the file already exists and is not empty.
433+
"""
434+
if os.path.isfile(path) and os.stat(path).st_size > 0:
435+
raise IOError(f"File {path} already exists!")
436+
with open(path, "w") as f:
437+
f.write(self.encode())
438+
439+
@classmethod
440+
def load(cls, path: str) -> Address:
441+
"""
442+
Load an Address object from a file.
443+
444+
Args:
445+
path (str): The file path to load the object from.
446+
447+
Returns:
448+
Address: The loaded Address object.
449+
"""
450+
with open(path) as f:
451+
return cls.decode(f.read())

pycardano/key.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
import json
6-
import os
76
from typing import Optional, Type
87

98
from nacl.encoding import RawEncoder
@@ -74,7 +73,7 @@ def to_primitive(self) -> bytes:
7473
def from_primitive(cls: Type["Key"], value: bytes) -> Key:
7574
return cls(value)
7675

77-
def to_json(self) -> str:
76+
def to_json(self, **kwargs) -> str:
7877
"""Serialize the key to JSON.
7978
8079
The json output has three fields: "type", "description", and "cborHex".
@@ -123,18 +122,6 @@ def from_json(cls: Type[Key], data: str, validate_type=False) -> Key:
123122
description=obj["description"],
124123
)
125124

126-
def save(self, path: str):
127-
if os.path.isfile(path):
128-
if os.stat(path).st_size > 0:
129-
raise IOError(f"File {path} already exists!")
130-
with open(path, "w") as f:
131-
f.write(self.to_json())
132-
133-
@classmethod
134-
def load(cls, path: str):
135-
with open(path) as f:
136-
return cls.from_json(f.read())
137-
138125
def __bytes__(self):
139126
return self.payload
140127

pycardano/serialization.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import json
6+
import os
57
import re
68
import typing
79
from collections import OrderedDict, UserList, defaultdict
@@ -535,6 +537,119 @@ def from_cbor(cls: Type[CBORBase], payload: Union[str, bytes]) -> CBORBase:
535537
def __repr__(self):
536538
return pformat(vars(self), indent=2)
537539

540+
@property
541+
def json_type(self) -> str:
542+
"""
543+
Return the class name of the CBORSerializable object.
544+
545+
This property provides a default string representing the type of the object for use in JSON serialization.
546+
547+
Returns:
548+
str: The class name of the object.
549+
"""
550+
return self.__class__.__name__
551+
552+
@property
553+
def json_description(self) -> str:
554+
"""
555+
Return the docstring of the CBORSerializable object's class.
556+
557+
This property provides a default string description of the object for use in JSON serialization.
558+
559+
Returns:
560+
str: The docstring of the object's class.
561+
"""
562+
return self.__class__.__doc__ or "Generated with PyCardano"
563+
564+
def to_json(self, **kwargs) -> str:
565+
"""
566+
Convert the CBORSerializable object to a JSON string containing type, description, and CBOR hex.
567+
568+
This method returns a JSON representation of the object, including its type, description, and CBOR hex encoding.
569+
570+
Args:
571+
**kwargs: Additional keyword arguments that can include:
572+
- key_type (str): The type to use in the JSON output. Defaults to the class name.
573+
- description (str): The description to use in the JSON output. Defaults to the class docstring.
574+
575+
Returns:
576+
str: The JSON string representation of the object.
577+
"""
578+
key_type = kwargs.pop("key_type", self.json_type)
579+
description = kwargs.pop("description", self.json_description)
580+
return json.dumps(
581+
{
582+
"type": key_type,
583+
"description": description,
584+
"cborHex": self.to_cbor_hex(),
585+
},
586+
indent=2,
587+
)
588+
589+
@classmethod
590+
def from_json(cls: Type[CBORSerializable], data: str) -> CBORSerializable:
591+
"""
592+
Load a CBORSerializable object from a JSON string containing its CBOR hex representation.
593+
594+
Args:
595+
data (str): The JSON string to load the object from.
596+
597+
Returns:
598+
CBORSerializable: The loaded CBORSerializable object.
599+
600+
Raises:
601+
DeserializeException: If the loaded object is not of the expected type.
602+
"""
603+
obj = json.loads(data)
604+
605+
k = cls.from_cbor(obj["cborHex"])
606+
607+
if not isinstance(k, cls):
608+
raise DeserializeException(
609+
f"Expected type {cls.__name__} but got {type(k).__name__}."
610+
)
611+
612+
return k
613+
614+
def save(
615+
self,
616+
path: str,
617+
key_type: Optional[str] = None,
618+
description: Optional[str] = None,
619+
):
620+
"""
621+
Save the CBORSerializable object to a file in JSON format.
622+
623+
This method writes the object's JSON representation to the specified file path.
624+
It raises an error if the file already exists and is not empty.
625+
626+
Args:
627+
path (str): The file path to save the object to.
628+
key_type (str, optional): The type to use in the JSON output.
629+
description (str, optional): The description to use in the JSON output.
630+
631+
Raises:
632+
IOError: If the file already exists and is not empty.
633+
"""
634+
if os.path.isfile(path) and os.stat(path).st_size > 0:
635+
raise IOError(f"File {path} already exists!")
636+
with open(path, "w") as f:
637+
f.write(self.to_json(key_type=key_type, description=description))
638+
639+
@classmethod
640+
def load(cls, path: str):
641+
"""
642+
Load a CBORSerializable object from a file containing its JSON representation.
643+
644+
Args:
645+
path (str): The file path to load the object from.
646+
647+
Returns:
648+
CBORSerializable: The loaded CBORSerializable object.
649+
"""
650+
with open(path) as f:
651+
return cls.from_json(f.read())
652+
538653

539654
def _restore_dataclass_field(
540655
f: Field, v: Primitive

pycardano/transaction.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44

55
from copy import deepcopy
66
from dataclasses import dataclass, field
7-
from pprint import pformat
87
from typing import Any, Callable, List, Optional, Type, Union
98

109
import cbor2
1110
from cbor2 import CBORTag
1211
from nacl.encoding import RawEncoder
1312
from nacl.hash import blake2b
13+
from pprintpp import pformat
1414

1515
from pycardano.address import Address
1616
from pycardano.certificate import Certificate
@@ -694,6 +694,18 @@ class Transaction(ArrayCBORSerializable):
694694

695695
auxiliary_data: Optional[AuxiliaryData] = None
696696

697+
@property
698+
def json_type(self) -> str:
699+
return (
700+
"Unwitnessed Tx ConwayEra"
701+
if self.transaction_witness_set.is_empty()
702+
else "Signed Tx ConwayEra"
703+
)
704+
705+
@property
706+
def json_description(self) -> str:
707+
return "Ledger Cddl Format"
708+
697709
@property
698710
def id(self) -> TransactionId:
699711
return self.transaction_body.id

pycardano/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def fee(
8080
"""Calculate fee based on the length of a transaction's CBOR bytes and script execution.
8181
8282
Args:
83-
context (ChainConext): A chain context.
83+
context (ChainContext): A chain context.
8484
length (int): The length of CBOR bytes, which could usually be derived
8585
by `len(tx.to_cbor())`.
8686
exec_steps (int): Number of execution steps run by plutus scripts in the transaction.
@@ -201,7 +201,7 @@ def min_lovelace_pre_alonzo(
201201
def min_lovelace_post_alonzo(output: TransactionOutput, context: ChainContext) -> int:
202202
"""Calculate minimum lovelace a transaction output needs to hold post alonzo.
203203
204-
This implementation is copied from the origianl Haskell implementation:
204+
This implementation is copied from the original Haskell implementation:
205205
https://github.com/input-output-hk/cardano-ledger/blob/eb053066c1d3bb51fb05978eeeab88afc0b049b2/eras/babbage/impl/src/Cardano/Ledger/Babbage/Rules/Utxo.hs#L242-L265
206206
207207
Args:

pycardano/witness.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from dataclasses import dataclass, field
66
from typing import Any, List, Optional, Type, Union
77

8+
from pprintpp import pformat
9+
810
from pycardano.key import ExtendedVerificationKey, VerificationKey
911
from pycardano.nativescript import NativeScript
1012
from pycardano.plutus import (
@@ -30,6 +32,14 @@ class VerificationKeyWitness(ArrayCBORSerializable):
3032
vkey: Union[VerificationKey, ExtendedVerificationKey]
3133
signature: bytes
3234

35+
@property
36+
def json_type(self) -> str:
37+
return "TxWitness ConwayEra"
38+
39+
@property
40+
def json_description(self) -> str:
41+
return "Key Witness ShelleyEra"
42+
3343
def __post_init__(self):
3444
# When vkey is in extended format, we need to convert it to non-extended, so it can match the
3545
# key hash of the input address we are trying to spend.
@@ -46,6 +56,26 @@ def from_primitive(
4656
signature=values[1],
4757
)
4858

59+
def to_shallow_primitive(self) -> Union[list, tuple]:
60+
"""Convert to a shallow primitive representation."""
61+
return [self.vkey.to_primitive(), self.signature]
62+
63+
def __eq__(self, other):
64+
if not isinstance(other, VerificationKeyWitness):
65+
return False
66+
else:
67+
return (
68+
self.vkey.payload == other.vkey.payload
69+
and self.signature == other.signature
70+
)
71+
72+
def __repr__(self):
73+
fields = {
74+
"vkey": self.vkey.payload.hex(),
75+
"signature": self.signature.hex(),
76+
}
77+
return pformat(fields, indent=2)
78+
4979

5080
@dataclass(repr=False)
5181
class TransactionWitnessSet(MapCBORSerializable):
@@ -126,3 +156,16 @@ def __post_init__(self):
126156
self.plutus_v2_script = NonEmptyOrderedSet(self.plutus_v2_script)
127157
if isinstance(self.plutus_v3_script, list):
128158
self.plutus_v3_script = NonEmptyOrderedSet(self.plutus_v3_script)
159+
160+
def is_empty(self) -> bool:
161+
"""Check if the witness set is empty."""
162+
return (
163+
not self.vkey_witnesses
164+
and not self.native_scripts
165+
and not self.bootstrap_witness
166+
and not self.plutus_v1_script
167+
and not self.plutus_data
168+
and not self.redeemer
169+
and not self.plutus_v2_script
170+
and not self.plutus_v3_script
171+
)

0 commit comments

Comments
 (0)