Skip to content

Add TextEnvelope class for saving and loading Witness and Transaction files #448

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion pycardano/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@

from __future__ import annotations

import os
from enum import Enum
from typing import Type, Union
from typing import Optional, Type, Union

from typing_extensions import override

from pycardano.crypto.bech32 import decode, encode
from pycardano.exception import (
Expand Down Expand Up @@ -406,3 +409,43 @@

def __repr__(self):
return f"{self.encode()}"

@override
def save(
self,
path: str,
key_type: Optional[str] = None,
description: Optional[str] = None,
):
"""
Save the Address object to a file.

This method writes the object's JSON representation to the specified file path.
It raises an error if the file already exists and is not empty.

Args:
path (str): The file path to save the object to.
key_type (str, optional): Not used in this context, but can be included for consistency.
description (str, optional): Not used in this context, but can be included for consistency.

Raises:
IOError: If the file already exists and is not empty.
"""
if os.path.isfile(path) and os.stat(path).st_size > 0:
raise IOError(f"File {path} already exists!")

Check warning on line 435 in pycardano/address.py

View check run for this annotation

Codecov / codecov/patch

pycardano/address.py#L435

Added line #L435 was not covered by tests
with open(path, "w") as f:
f.write(self.encode())

@classmethod
def load(cls, path: str) -> Address:
"""
Load an Address object from a file.

Args:
path (str): The file path to load the object from.

Returns:
Address: The loaded Address object.
"""
with open(path) as f:
return cls.decode(f.read())
15 changes: 1 addition & 14 deletions pycardano/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import json
import os
from typing import Optional, Type

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

def to_json(self) -> str:
def to_json(self, **kwargs) -> str:
"""Serialize the key to JSON.

The json output has three fields: "type", "description", and "cborHex".
Expand Down Expand Up @@ -123,18 +122,6 @@ def from_json(cls: Type[Key], data: str, validate_type=False) -> Key:
description=obj["description"],
)

def save(self, path: str):
if os.path.isfile(path):
if os.stat(path).st_size > 0:
raise IOError(f"File {path} already exists!")
with open(path, "w") as f:
f.write(self.to_json())

@classmethod
def load(cls, path: str):
with open(path) as f:
return cls.from_json(f.read())

def __bytes__(self):
return self.payload

Expand Down
114 changes: 114 additions & 0 deletions pycardano/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import json
import os
import re
import typing
from collections import OrderedDict, UserList, defaultdict
Expand Down Expand Up @@ -535,6 +537,118 @@
def __repr__(self):
return pformat(vars(self), indent=2)

@property
def json_type(self) -> str:
"""
Return the class name of the CBORSerializable object.

This property provides a default string representing the type of the object for use in JSON serialization.

Returns:
str: The class name of the object.
"""
return self.__class__.__name__

Check warning on line 550 in pycardano/serialization.py

View check run for this annotation

Codecov / codecov/patch

pycardano/serialization.py#L550

Added line #L550 was not covered by tests

@property
def json_description(self) -> str:
"""
Return the docstring of the CBORSerializable object's class.

This property provides a default string description of the object for use in JSON serialization.

Returns:
str: The docstring of the object's class.
"""
return "Generated with PyCardano"

Check warning on line 562 in pycardano/serialization.py

View check run for this annotation

Codecov / codecov/patch

pycardano/serialization.py#L562

Added line #L562 was not covered by tests

def to_json(self, **kwargs) -> str:
"""
Convert the CBORSerializable object to a JSON string containing type, description, and CBOR hex.

This method returns a JSON representation of the object, including its type, description, and CBOR hex encoding.

Args:
**kwargs: Additional keyword arguments that can include:
- key_type (str): The type to use in the JSON output. Defaults to the class name.
- description (str): The description to use in the JSON output. Defaults to the class docstring.

Returns:
str: The JSON string representation of the object.
"""
key_type = kwargs.pop("key_type", self.json_type)
description = kwargs.pop("description", self.json_description)
return json.dumps(
{
"type": key_type,
"description": description,
"cborHex": self.to_cbor_hex(),
}
)

@classmethod
def from_json(cls: Type[CBORSerializable], data: str) -> CBORSerializable:
"""
Load a CBORSerializable object from a JSON string containing its CBOR hex representation.

Args:
data (str): The JSON string to load the object from.

Returns:
CBORSerializable: The loaded CBORSerializable object.

Raises:
DeserializeException: If the loaded object is not of the expected type.
"""
obj = json.loads(data)

k = cls.from_cbor(obj["cborHex"])

if not isinstance(k, cls):
raise DeserializeException(

Check warning on line 607 in pycardano/serialization.py

View check run for this annotation

Codecov / codecov/patch

pycardano/serialization.py#L607

Added line #L607 was not covered by tests
f"Expected type {cls.__name__} but got {type(k).__name__}."
)

return k

def save(
self,
path: str,
key_type: Optional[str] = None,
description: Optional[str] = None,
):
"""
Save the CBORSerializable object to a file in JSON format.

This method writes the object's JSON representation to the specified file path.
It raises an error if the file already exists and is not empty.

Args:
path (str): The file path to save the object to.
key_type (str, optional): The type to use in the JSON output.
description (str, optional): The description to use in the JSON output.

Raises:
IOError: If the file already exists and is not empty.
"""
if os.path.isfile(path) and os.stat(path).st_size > 0:
raise IOError(f"File {path} already exists!")
with open(path, "w") as f:
f.write(self.to_json(key_type=key_type, description=description))

@classmethod
def load(cls, path: str):
"""
Load a CBORSerializable object from a file containing its JSON representation.

Args:
path (str): The file path to load the object from.

Returns:
CBORSerializable: The loaded CBORSerializable object.
"""
with open(path) as f:
return cls.from_json(f.read())


def _restore_dataclass_field(
f: Field, v: Primitive
Expand Down
14 changes: 13 additions & 1 deletion pycardano/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

from copy import deepcopy
from dataclasses import dataclass, field
from pprint import pformat
from typing import Any, Callable, List, Optional, Type, Union

import cbor2
from cbor2 import CBORTag
from nacl.encoding import RawEncoder
from nacl.hash import blake2b
from pprintpp import pformat

from pycardano.address import Address
from pycardano.certificate import Certificate
Expand Down Expand Up @@ -694,6 +694,18 @@ class Transaction(ArrayCBORSerializable):

auxiliary_data: Optional[AuxiliaryData] = None

@property
def json_type(self) -> str:
return (
"Unwitnessed Tx ConwayEra"
if self.transaction_witness_set.is_empty()
else "Signed Tx ConwayEra"
)

@property
def json_description(self) -> str:
return "Ledger Cddl Format"

@property
def id(self) -> TransactionId:
return self.transaction_body.id
4 changes: 2 additions & 2 deletions pycardano/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def fee(
"""Calculate fee based on the length of a transaction's CBOR bytes and script execution.

Args:
context (ChainConext): A chain context.
context (ChainContext): A chain context.
length (int): The length of CBOR bytes, which could usually be derived
by `len(tx.to_cbor())`.
exec_steps (int): Number of execution steps run by plutus scripts in the transaction.
Expand Down Expand Up @@ -201,7 +201,7 @@ def min_lovelace_pre_alonzo(
def min_lovelace_post_alonzo(output: TransactionOutput, context: ChainContext) -> int:
"""Calculate minimum lovelace a transaction output needs to hold post alonzo.

This implementation is copied from the origianl Haskell implementation:
This implementation is copied from the original Haskell implementation:
https://github.com/input-output-hk/cardano-ledger/blob/eb053066c1d3bb51fb05978eeeab88afc0b049b2/eras/babbage/impl/src/Cardano/Ledger/Babbage/Rules/Utxo.hs#L242-L265

Args:
Expand Down
39 changes: 39 additions & 0 deletions pycardano/witness.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from dataclasses import dataclass, field
from typing import Any, List, Optional, Type, Union

from pprintpp import pformat

from pycardano.key import ExtendedVerificationKey, VerificationKey
from pycardano.nativescript import NativeScript
from pycardano.plutus import (
Expand All @@ -30,6 +32,14 @@ class VerificationKeyWitness(ArrayCBORSerializable):
vkey: Union[VerificationKey, ExtendedVerificationKey]
signature: bytes

@property
def json_type(self) -> str:
return "TxWitness ConwayEra"

@property
def json_description(self) -> str:
return "Key Witness ShelleyEra"

def __post_init__(self):
# When vkey is in extended format, we need to convert it to non-extended, so it can match the
# key hash of the input address we are trying to spend.
Expand All @@ -46,6 +56,22 @@ def from_primitive(
signature=values[1],
)

def __eq__(self, other):
if not isinstance(other, VerificationKeyWitness):
return False
else:
return (
self.vkey.payload == other.vkey.payload
and self.signature == other.signature
)

def __repr__(self):
fields = {
"vkey": self.vkey.payload.hex(),
"signature": self.signature.hex(),
}
return pformat(fields, indent=2)


@dataclass(repr=False)
class TransactionWitnessSet(MapCBORSerializable):
Expand Down Expand Up @@ -126,3 +152,16 @@ def __post_init__(self):
self.plutus_v2_script = NonEmptyOrderedSet(self.plutus_v2_script)
if isinstance(self.plutus_v3_script, list):
self.plutus_v3_script = NonEmptyOrderedSet(self.plutus_v3_script)

def is_empty(self) -> bool:
"""Check if the witness set is empty."""
return (
not self.vkey_witnesses
and not self.native_scripts
and not self.bootstrap_witness
and not self.plutus_v1_script
and not self.plutus_data
and not self.redeemer
and not self.plutus_v2_script
and not self.plutus_v3_script
)
Loading