From 9c8956a2191b8d52e0eb89190a72a3f5a19b1dc2 Mon Sep 17 00:00:00 2001 From: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:11:32 +0100 Subject: [PATCH] Align method serialization in Python (#1824) * Align method serialization in Python; Fix TimelockUnlockConditionDto * Fix test_output_id * Don't use dangerous default value [] * Remove early return, update comment * Update bindings/python/iota_sdk/common.py Co-authored-by: /alex/ --------- Co-authored-by: /alex/ Co-authored-by: Thibault Martinez --- bindings/python/iota_sdk/__init__.py | 4 +- .../python/iota_sdk/client/_node_core_api.py | 2 +- .../iota_sdk/client/_node_indexer_api.py | 28 ++---- bindings/python/iota_sdk/client/client.py | 97 +++---------------- bindings/python/iota_sdk/client/common.py | 30 ++++++ bindings/python/iota_sdk/common.py | 51 ++++++++++ bindings/python/iota_sdk/types/output.py | 2 +- bindings/python/iota_sdk/types/output_id.py | 8 +- bindings/python/iota_sdk/wallet/common.py | 48 +-------- bindings/python/iota_sdk/wallet/wallet.py | 4 +- bindings/python/tests/test_offline.py | 15 ++- .../block/output/unlock_condition/timelock.rs | 1 + 12 files changed, 121 insertions(+), 169 deletions(-) create mode 100644 bindings/python/iota_sdk/client/common.py create mode 100644 bindings/python/iota_sdk/common.py diff --git a/bindings/python/iota_sdk/__init__.py b/bindings/python/iota_sdk/__init__.py index 2b58358006..69bc5522d3 100644 --- a/bindings/python/iota_sdk/__init__.py +++ b/bindings/python/iota_sdk/__init__.py @@ -3,7 +3,9 @@ from .external import * -from .client.client import Client, NodeIndexerAPI, ClientError +from .common import custom_encoder +from .client.client import Client, NodeIndexerAPI +from .client.common import ClientError from .client._high_level_api import GenerateAddressesOptions, GenerateAddressOptions from .utils import Utils from .wallet.wallet import Wallet, WalletOptions diff --git a/bindings/python/iota_sdk/client/_node_core_api.py b/bindings/python/iota_sdk/client/_node_core_api.py index 079c7395dd..f149e8c961 100644 --- a/bindings/python/iota_sdk/client/_node_core_api.py +++ b/bindings/python/iota_sdk/client/_node_core_api.py @@ -80,7 +80,7 @@ def post_block(self, block: Block) -> HexStr: The block id of the posted block. """ return self._call_method('postBlock', { - 'block': block.to_dict() + 'block': block }) def get_block(self, block_id: HexStr) -> Block: diff --git a/bindings/python/iota_sdk/client/_node_indexer_api.py b/bindings/python/iota_sdk/client/_node_indexer_api.py index cdc24277a5..db684098d4 100644 --- a/bindings/python/iota_sdk/client/_node_indexer_api.py +++ b/bindings/python/iota_sdk/client/_node_indexer_api.py @@ -261,10 +261,8 @@ def output_ids( The corresponding output IDs of the outputs. """ - query_parameters_camelized = query_parameters.to_dict() - response = self._call_method('outputIds', { - 'queryParameters': query_parameters_camelized, + 'queryParameters': query_parameters, }) return OutputIdsResponse(response) @@ -276,10 +274,8 @@ def basic_output_ids( The corresponding output IDs of the basic outputs. """ - query_parameters_camelized = query_parameters.to_dict() - response = self._call_method('basicOutputIds', { - 'queryParameters': query_parameters_camelized, + 'queryParameters': query_parameters, }) return OutputIdsResponse(response) @@ -291,10 +287,8 @@ def account_output_ids( The corresponding output IDs of the account outputs. """ - query_parameters_camelized = query_parameters.to_dict() - response = self._call_method('accountOutputIds', { - 'queryParameters': query_parameters_camelized, + 'queryParameters': query_parameters, }) return OutputIdsResponse(response) @@ -316,10 +310,8 @@ def anchor_output_ids( The corresponding output IDs of the anchor outputs. """ - query_parameters_camelized = query_parameters.to_dict() - response = self._call_method('anchorOutputIds', { - 'queryParameters': query_parameters_camelized, + 'queryParameters': query_parameters, }) return OutputIdsResponse(response) @@ -341,10 +333,8 @@ def delegation_output_ids( The corresponding output IDs of the delegation outputs. """ - query_parameters_camelized = query_parameters.to_dict() - response = self._call_method('delegationOutputIds', { - 'queryParameters': query_parameters_camelized, + 'queryParameters': query_parameters, }) return OutputIdsResponse(response) @@ -366,10 +356,8 @@ def foundry_output_ids( The corresponding output IDs of the foundry outputs. """ - query_parameters_camelized = query_parameters.to_dict() - response = self._call_method('foundryOutputIds', { - 'queryParameters': query_parameters_camelized, + 'queryParameters': query_parameters, }) return OutputIdsResponse(response) @@ -391,10 +379,8 @@ def nft_output_ids( The corresponding output IDs of the NFT outputs. """ - query_parameters_camelized = query_parameters.to_dict() - response = self._call_method('nftOutputIds', { - 'queryParameters': query_parameters_camelized, + 'queryParameters': query_parameters, }) return OutputIdsResponse(response) diff --git a/bindings/python/iota_sdk/client/client.py b/bindings/python/iota_sdk/client/client.py index fdc26d3287..c17ae7550e 100644 --- a/bindings/python/iota_sdk/client/client.py +++ b/bindings/python/iota_sdk/client/client.py @@ -1,16 +1,17 @@ # Copyright 2023 IOTA Stiftung # SPDX-License-Identifier: Apache-2.0 -from json import dumps, loads +from json import dumps from datetime import timedelta from typing import Any, Dict, List, Optional, Union import humps -from iota_sdk.external import create_client, call_client_method, listen_mqtt +from iota_sdk.external import create_client, listen_mqtt from iota_sdk.client._node_core_api import NodeCoreAPI from iota_sdk.client._node_indexer_api import NodeIndexerAPI from iota_sdk.client._high_level_api import HighLevelAPI from iota_sdk.client._utils import ClientUtils +from iota_sdk.client.common import _call_client_method_routine from iota_sdk.types.block.block import UnsignedBlock from iota_sdk.types.common import HexStr, Node from iota_sdk.types.feature import Feature @@ -21,10 +22,6 @@ from iota_sdk.types.unlock_condition import UnlockCondition -class ClientError(Exception): - """Represents a client error.""" - - class Client(NodeCoreAPI, NodeIndexerAPI, HighLevelAPI, ClientUtils): """Represents an IOTA client. @@ -117,6 +114,7 @@ def get_remaining_nano_seconds(duration: timedelta): else: self.handle = client_handle + @_call_client_method_routine def _call_method(self, name, data=None): """Dumps json string and calls `call_client_method()` """ @@ -125,20 +123,7 @@ def _call_method(self, name, data=None): } if data: message['data'] = data - message = dumps(message) - - # Send message to the Rust library - response = call_client_method(self.handle, message) - - json_response = loads(response) - - if "type" in json_response: - if json_response["type"] == "error": - raise ClientError(json_response['payload']) - - if "payload" in json_response: - return json_response['payload'] - return response + return message def get_handle(self): """Get the client handle. @@ -171,26 +156,11 @@ def build_account_output(self, The account output as dict. """ - unlock_conditions = [unlock_condition.to_dict() - for unlock_condition in unlock_conditions] - - if features: - features = [feature.to_dict() for feature in features] - if immutable_features: - immutable_features = [immutable_feature.to_dict() - for immutable_feature in immutable_features] - - if amount: - amount = str(amount) - - if mana: - mana = str(mana) - return deserialize_output(self._call_method('buildAccountOutput', { 'accountId': account_id, 'unlockConditions': unlock_conditions, - 'amount': amount, - 'mana': mana, + 'amount': None if amount is None else str(amount), + 'mana': None if mana is None else str(mana), 'foundryCounter': foundry_counter, 'features': features, 'immutableFeatures': immutable_features @@ -213,22 +183,10 @@ def build_basic_output(self, The basic output as dict. """ - unlock_conditions = [unlock_condition.to_dict() - for unlock_condition in unlock_conditions] - - if features: - features = [feature.to_dict() for feature in features] - - if amount: - amount = str(amount) - - if mana: - mana = str(mana) - return deserialize_output(self._call_method('buildBasicOutput', { 'unlockConditions': unlock_conditions, - 'amount': amount, - 'mana': mana, + 'amount': None if amount is None else str(amount), + 'mana': None if mana is None else str(mana), 'features': features, })) @@ -253,23 +211,11 @@ def build_foundry_output(self, The foundry output as dict. """ - unlock_conditions = [unlock_condition.to_dict() - for unlock_condition in unlock_conditions] - - if features: - features = [feature.to_dict() for feature in features] - if immutable_features: - immutable_features = [immutable_feature.to_dict() - for immutable_feature in immutable_features] - - if amount: - amount = str(amount) - return deserialize_output(self._call_method('buildFoundryOutput', { 'serialNumber': serial_number, - 'tokenScheme': token_scheme.to_dict(), + 'tokenScheme': token_scheme, 'unlockConditions': unlock_conditions, - 'amount': amount, + 'amount': None if amount is None else str(amount), 'features': features, 'immutableFeatures': immutable_features })) @@ -295,26 +241,11 @@ def build_nft_output(self, The NFT output as dict. """ - unlock_conditions = [unlock_condition.to_dict() - for unlock_condition in unlock_conditions] - - if features: - features = [feature.to_dict() for feature in features] - if immutable_features: - immutable_features = [immutable_feature.to_dict() - for immutable_feature in immutable_features] - - if amount: - amount = str(amount) - - if mana: - mana = str(mana) - return deserialize_output(self._call_method('buildNftOutput', { 'nftId': nft_id, 'unlockConditions': unlock_conditions, - 'amount': amount, - 'mana': mana, + 'amount': None if amount is None else str(amount), + 'mana': None if mana is None else str(mana), 'features': features, 'immutableFeatures': immutable_features })) @@ -358,8 +289,6 @@ def build_basic_block( Returns: An unsigned block. """ - if payload is not None: - payload = payload.to_dict() result = self._call_method('buildBasicBlock', { 'issuerId': issuer_id, 'payload': payload, diff --git a/bindings/python/iota_sdk/client/common.py b/bindings/python/iota_sdk/client/common.py new file mode 100644 index 0000000000..ba12cdf348 --- /dev/null +++ b/bindings/python/iota_sdk/client/common.py @@ -0,0 +1,30 @@ +# Copyright 2024 IOTA Stiftung +# SPDX-License-Identifier: Apache-2.0 + +import json +from iota_sdk import call_client_method +from iota_sdk.common import custom_encoder + + +def _call_client_method_routine(func): + """The routine of dump json string and call call_client_method(). + """ + def wrapper(*args, **kwargs): + message = custom_encoder(func, *args, **kwargs) + # Send message to the Rust library + response = call_client_method(args[0].handle, message) + + json_response = json.loads(response) + + if "type" in json_response: + if json_response["type"] == "error" or json_response["type"] == "panic": + raise ClientError(json_response['payload']) + + if "payload" in json_response: + return json_response['payload'] + return response + return wrapper + + +class ClientError(Exception): + """A client error.""" diff --git a/bindings/python/iota_sdk/common.py b/bindings/python/iota_sdk/common.py new file mode 100644 index 0000000000..0b624d6ea2 --- /dev/null +++ b/bindings/python/iota_sdk/common.py @@ -0,0 +1,51 @@ +# Copyright 2024 IOTA Stiftung +# SPDX-License-Identifier: Apache-2.0 + +import json +from json import dumps, JSONEncoder +from enum import Enum +import humps + + +def custom_encoder(func, *args, **kwargs): + """Converts the parameters to a JSON string and removes None values. + """ + class MyEncoder(JSONEncoder): + """Custom encoder + """ + # pylint: disable=too-many-return-statements + + def default(self, o): + to_dict_method = getattr(o, "to_dict", None) + if callable(to_dict_method): + return o.to_dict() + if isinstance(o, str): + return o + if isinstance(o, Enum): + return o.__dict__ + if isinstance(o, dict): + return o + if hasattr(o, "__dict__"): + obj_dict = o.__dict__ + items_method = getattr(self, "items", None) + if callable(items_method): + for k, v in obj_dict.items(): + obj_dict[k] = dumps(v, cls=MyEncoder) + return obj_dict + return o + message = func(*args, **kwargs) + for k, v in message.items(): + if not isinstance(v, str): + message[k] = json.loads(dumps(v, cls=MyEncoder)) + + def remove_none(obj): + if isinstance(obj, (list, tuple, set)): + return type(obj)(remove_none(x) for x in obj if x is not None) + if isinstance(obj, dict): + return type(obj)((remove_none(k), remove_none(v)) + for k, v in obj.items() if k is not None and v is not None) + return obj + + message_null_filtered = remove_none(message) + message = dumps(humps.camelize(message_null_filtered)) + return message diff --git a/bindings/python/iota_sdk/types/output.py b/bindings/python/iota_sdk/types/output.py index 37adc08d30..005ed65c54 100644 --- a/bindings/python/iota_sdk/types/output.py +++ b/bindings/python/iota_sdk/types/output.py @@ -301,7 +301,7 @@ def deserialize_output(d: Dict[str, Any]) -> Output: Arguments: * `d`: A dictionary that is expected to have a key called 'type' which specifies the type of the returned value. """ - output_type = dict['type'] + output_type = d['type'] if output_type == OutputType.Basic: return BasicOutput.from_dict(d) if output_type == OutputType.Account: diff --git a/bindings/python/iota_sdk/types/output_id.py b/bindings/python/iota_sdk/types/output_id.py index 2a2742aef6..0a824cfd47 100644 --- a/bindings/python/iota_sdk/types/output_id.py +++ b/bindings/python/iota_sdk/types/output_id.py @@ -24,9 +24,7 @@ def __init__(self, transaction_id: HexStr, output_index: int): raise ValueError('transaction_id must start with 0x') # Validate that it has only valid hex characters int(transaction_id[2:], 16) - if output_index not in range(0, 129): - raise ValueError('output_index must be a value from 0 to 128') - output_index_hex = (output_index).to_bytes(2, "little").hex() + output_index_hex = (output_index).to_bytes(4, "little").hex() self.output_id = transaction_id + output_index_hex self.transaction_id = transaction_id self.output_index = output_index @@ -43,9 +41,9 @@ def from_string(cls, output_id: HexStr): """ obj = cls.__new__(cls) super(OutputId, obj).__init__() - if len(output_id) != 70: + if len(output_id) != 78: raise ValueError( - 'output_id length must be 70 characters with 0x prefix') + 'output_id length must be 78 characters with 0x prefix') if not output_id.startswith('0x'): raise ValueError('transaction_id must start with 0x') # Validate that it has only valid hex characters diff --git a/bindings/python/iota_sdk/wallet/common.py b/bindings/python/iota_sdk/wallet/common.py index 131591917e..e91168bf01 100644 --- a/bindings/python/iota_sdk/wallet/common.py +++ b/bindings/python/iota_sdk/wallet/common.py @@ -2,57 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 import json -from json import dumps, JSONEncoder -from enum import Enum -import humps from iota_sdk import call_wallet_method +from iota_sdk.common import custom_encoder -def _call_method_routine(func): +def _call_wallet_method_routine(func): """The routine of dump json string and call call_wallet_method(). """ def wrapper(*args, **kwargs): - class MyEncoder(JSONEncoder): - """Custom encoder - """ - - # pylint: disable=too-many-return-statements - def default(self, o): - to_dict_method = getattr(o, "to_dict", None) - if callable(to_dict_method): - return o.to_dict() - if isinstance(o, str): - return o - if isinstance(o, Enum): - return o.__dict__ - if isinstance(o, dict): - return o - if hasattr(o, "__dict__"): - obj_dict = o.__dict__ - - items_method = getattr(self, "items", None) - if callable(items_method): - for k, v in obj_dict.items(): - obj_dict[k] = dumps(v, cls=MyEncoder) - return obj_dict - return obj_dict - return o - message = func(*args, **kwargs) - - for k, v in message.items(): - if not isinstance(v, str): - message[k] = json.loads(dumps(v, cls=MyEncoder)) - - def remove_none(obj): - if isinstance(obj, (list, tuple, set)): - return type(obj)(remove_none(x) for x in obj if x is not None) - if isinstance(obj, dict): - return type(obj)((remove_none(k), remove_none(v)) - for k, v in obj.items() if k is not None and v is not None) - return obj - - message_null_filtered = remove_none(message) - message = dumps(humps.camelize(message_null_filtered)) + message = custom_encoder(func, *args, **kwargs) # Send message to the Rust library response = call_wallet_method(args[0].handle, message) diff --git a/bindings/python/iota_sdk/wallet/wallet.py b/bindings/python/iota_sdk/wallet/wallet.py index eb57979fa0..bc52528bdf 100644 --- a/bindings/python/iota_sdk/wallet/wallet.py +++ b/bindings/python/iota_sdk/wallet/wallet.py @@ -8,7 +8,7 @@ from iota_sdk import destroy_wallet, create_wallet, listen_wallet, get_client_from_wallet, get_secret_manager_from_wallet, Client from iota_sdk.secret_manager.secret_manager import LedgerNanoSecretManager, MnemonicSecretManager, StrongholdSecretManager, SeedSecretManager, SecretManager -from iota_sdk.wallet.common import _call_method_routine +from iota_sdk.wallet.common import _call_wallet_method_routine from iota_sdk.wallet.prepared_transaction import PreparedTransaction, PreparedCreateTokenTransaction from iota_sdk.wallet.sync_options import SyncOptions from iota_sdk.types.balance import Balance @@ -63,7 +63,7 @@ def get_handle(self): """ return self.handle - @_call_method_routine + @_call_wallet_method_routine def _call_method(self, name: str, data=None): message = { 'name': name diff --git a/bindings/python/tests/test_offline.py b/bindings/python/tests/test_offline.py index e827558113..8a4a892a8c 100644 --- a/bindings/python/tests/test_offline.py +++ b/bindings/python/tests/test_offline.py @@ -61,12 +61,12 @@ def test_output_id(self): output_index = 42 output_id = OutputId(transaction_id, output_index) assert repr( - output_id) == '0x52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00' + output_id) == '0x52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a000000' new_output_id = OutputId.from_string( - '0x52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00') + '0x52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a0000000000') assert repr( - new_output_id) == '0x52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00' + new_output_id) == '0x52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a0000000000' assert new_output_id.transaction_id == transaction_id assert new_output_id.output_index == output_index @@ -79,16 +79,13 @@ def test_output_id(self): transaction_id_invalid_hex_char = '0xz2fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649' with self.assertRaises(ValueError): OutputId(transaction_id_invalid_hex_char, output_index) - invalid_output_index = 129 - with self.assertRaises(ValueError): - OutputId(transaction_id, invalid_output_index) - output_id_missing_0x_prefix = '52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00' + output_id_missing_0x_prefix = '52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00000000' with self.assertRaises(ValueError): OutputId.from_string(output_id_missing_0x_prefix) - output_id_invalid_hex_char = '0xz2fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00' + output_id_invalid_hex_char = '0xz2fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00000000' with self.assertRaises(ValueError): OutputId.from_string(output_id_invalid_hex_char) - output_id_invalid_hex_prefix = '0052fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00' + output_id_invalid_hex_prefix = '0052fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c6492a00000000' with self.assertRaises(ValueError): OutputId.from_string(output_id_invalid_hex_prefix) diff --git a/sdk/src/types/block/output/unlock_condition/timelock.rs b/sdk/src/types/block/output/unlock_condition/timelock.rs index 2b3f50ccd9..b9ac9aef90 100644 --- a/sdk/src/types/block/output/unlock_condition/timelock.rs +++ b/sdk/src/types/block/output/unlock_condition/timelock.rs @@ -55,6 +55,7 @@ pub(crate) mod dto { use crate::types::block::Error; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] struct TimelockUnlockConditionDto { #[serde(rename = "type")] kind: u8,