From 82a0f24f60355fe575cc5188725b051d8ffb0c21 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Thu, 23 Jan 2020 14:51:42 +0100 Subject: [PATCH] `get_bundles` can return multiple bundles - accept a list of tail transaction hashes instead of just one. - return multiple bundles in the same order as the input tail hashes - added test coverage - modified calls to GetBundlesCommand() in the lib WARNING: Breaking change! --- iota/api.py | 18 +-- iota/commands/extended/broadcast_bundle.py | 2 +- iota/commands/extended/get_bundles.py | 48 ++++---- iota/commands/extended/replay_bundle.py | 2 +- iota/commands/extended/utils.py | 2 +- test/commands/extended/get_bundles_test.py | 121 ++++++++++++++++++--- 6 files changed, 142 insertions(+), 51 deletions(-) diff --git a/iota/api.py b/iota/api.py index 983ad5f..19481c5 100644 --- a/iota/api.py +++ b/iota/api.py @@ -983,14 +983,14 @@ def get_account_data(self, start=0, stop=None, inclusion_states=False, security_ security_level=security_level ) - def get_bundles(self, transaction): - # type: (TransactionHash) -> dict + def get_bundles(self, transactions): + # type: (Iterable[TransactionHash]) -> dict """ Returns the bundle(s) associated with the specified transaction - hash. + hashes. - :param TransactionHash transaction: - Transaction hash. Must be a tail transaction. + :param Iterable[TransactionHash] transactions: + Transaction hashes. Must be a tail transaction. :return: ``dict`` with the following structure:: @@ -1001,15 +1001,15 @@ def get_bundles(self, transaction): always a list, even if only one bundle was found. } - :raise: - - :py:class:`iota.adapter.BadApiResponse` if any of the - bundles fails validation. + :raise :py:class:`iota.adapter.BadApiResponse`: + - if any of the bundles fails validation. + - if any of the bundles is not visible on the Tangle. References: - https://github.com/iotaledger/wiki/blob/master/api-proposal.md#getbundle """ - return extended.GetBundlesCommand(self.adapter)(transaction=transaction) + return extended.GetBundlesCommand(self.adapter)(transactions=transactions) def get_inputs( self, diff --git a/iota/commands/extended/broadcast_bundle.py b/iota/commands/extended/broadcast_bundle.py index a4cb893..b7d84b3 100644 --- a/iota/commands/extended/broadcast_bundle.py +++ b/iota/commands/extended/broadcast_bundle.py @@ -36,7 +36,7 @@ def _execute(self, request): # and validates it. # Returns List[List[TransactionTrytes]] # (outer list has one item in current implementation) - bundle = GetBundlesCommand(self.adapter)(transaction=request['tail_hash']) + bundle = GetBundlesCommand(self.adapter)(transactions=[request['tail_hash']]) BroadcastTransactionsCommand(self.adapter)(trytes=bundle[0]) return { 'trytes': bundle[0], diff --git a/iota/commands/extended/get_bundles.py b/iota/commands/extended/get_bundles.py index ac2575a..cb1acb3 100644 --- a/iota/commands/extended/get_bundles.py +++ b/iota/commands/extended/get_bundles.py @@ -8,8 +8,8 @@ from iota.commands import FilterCommand, RequestFilter from iota.commands.extended.traverse_bundle import TraverseBundleCommand from iota.exceptions import with_context -from iota.filters import Trytes from iota.transaction.validator import BundleValidator +from iota.filters import Trytes __all__ = [ 'GetBundlesCommand', @@ -31,35 +31,41 @@ def get_response_filter(self): pass def _execute(self, request): - transaction_hash = request['transaction'] # type: TransactionHash + transaction_hashes = request['transactions'] # type: Iterable[TransactionHash] + + bundles = [] + + # Fetch bundles one-by-one + for tx_hash in transaction_hashes: + bundle = TraverseBundleCommand(self.adapter)( + transaction=tx_hash + )['bundles'][0] # Currently 1 bundle only - bundle = TraverseBundleCommand(self.adapter)( - transaction=transaction_hash - )['bundles'][0] # Currently 1 bundle only + validator = BundleValidator(bundle) - validator = BundleValidator(bundle) + if not validator.is_valid(): + raise with_context( + exc=BadApiResponse( + 'Bundle failed validation (``exc.context`` has more info).', + ), - if not validator.is_valid(): - raise with_context( - exc=BadApiResponse( - 'Bundle failed validation (``exc.context`` has more info).', - ), + context={ + 'bundle': bundle, + 'errors': validator.errors, + }, + ) - context={ - 'bundle': bundle, - 'errors': validator.errors, - }, - ) + bundles.append(bundle) return { - # Always return a list, so that we have the necessary - # structure to return multiple bundles in a future - # iteration. - 'bundles': [bundle], + 'bundles': bundles, } class GetBundlesRequestFilter(RequestFilter): def __init__(self): super(GetBundlesRequestFilter, self).__init__({ - 'transaction': f.Required | Trytes(TransactionHash), + 'transactions': + f.Required | f.Array | f.FilterRepeater( + f.Required | Trytes(TransactionHash) + ) }) diff --git a/iota/commands/extended/replay_bundle.py b/iota/commands/extended/replay_bundle.py index aff8e1e..b2c2e68 100644 --- a/iota/commands/extended/replay_bundle.py +++ b/iota/commands/extended/replay_bundle.py @@ -34,7 +34,7 @@ def _execute(self, request): min_weight_magnitude = request['minWeightMagnitude'] # type: int transaction = request['transaction'] # type: TransactionHash - gb_response = GetBundlesCommand(self.adapter)(transaction=transaction) + gb_response = GetBundlesCommand(self.adapter)(transactions=[transaction]) # Note that we only replay the first bundle returned by # ``getBundles``. diff --git a/iota/commands/extended/utils.py b/iota/commands/extended/utils.py index 2ab0afd..3292a16 100644 --- a/iota/commands/extended/utils.py +++ b/iota/commands/extended/utils.py @@ -118,7 +118,7 @@ def get_bundles_from_transaction_hashes( # Find the bundles for each transaction. for txn in tail_transactions: - gb_response = GetBundlesCommand(adapter)(transaction=txn.hash) + gb_response = GetBundlesCommand(adapter)(transactions=[txn.hash]) txn_bundles = gb_response['bundles'] # type: List[Bundle] if inclusion_states: diff --git a/test/commands/extended/get_bundles_test.py b/test/commands/extended/get_bundles_test.py index 4d596fb..4c91e85 100644 --- a/test/commands/extended/get_bundles_test.py +++ b/test/commands/extended/get_bundles_test.py @@ -23,10 +23,16 @@ def setUp(self): super(GetBundlesRequestFilterTestCase, self).setUp() # noinspection SpellCheckingInspection - self.transaction = ( - 'TESTVALUE9DONTUSEINPRODUCTION99999KPZOTR' - 'VDB9GZDJGZSSDCBIX9QOK9PAV9RMDBGDXLDTIZTWQ' - ) + self.transactions = [ + ( + 'TESTVALUE9DONTUSEINPRODUCTION99999KPZOTR' + 'VDB9GZDJGZSSDCBIX9QOK9PAV9RMDBGDXLDTIZTWQ' + ), + ( + 'TESTVALUE9DONTUSEINPRODUCTION99999TAXQBF' + 'ZMUQLZ9RXRRXQOUSAMGAPEKTZNERIKSDYGHQA9999' + ), + ] def test_pass_happy_path(self): """ @@ -34,7 +40,7 @@ def test_pass_happy_path(self): """ # Raw trytes are extracted to match the IRI's JSON protocol. request = { - 'transaction': self.transaction, + 'transactions': self.transactions, } filter_ = self._filter(request) @@ -47,9 +53,14 @@ def test_pass_compatible_types(self): Request contains values that can be converted to the expected types. """ + # Convert first to TranscationHash + tx_hashes = [] + for tx in self.transactions: + tx_hashes.append(TransactionHash(tx)) + filter_ = self._filter({ # Any TrytesCompatible value will work here. - 'transaction': TransactionHash(self.transaction), + 'transactions': tx_hashes, }) self.assertFilterPasses(filter_) @@ -57,7 +68,7 @@ def test_pass_compatible_types(self): filter_.cleaned_data, { - 'transaction': self.transaction, + 'transactions': self.transactions, }, ) @@ -69,7 +80,7 @@ def test_fail_empty(self): {}, { - 'transaction': [f.FilterMapper.CODE_MISSING_KEY], + 'transactions': [f.FilterMapper.CODE_MISSING_KEY], }, ) @@ -79,7 +90,7 @@ def test_fail_unexpected_parameters(self): """ self.assertFilterErrors( { - 'transaction': TransactionHash(self.transaction), + 'transactions': self.transactions, # SAY "WHAT" AGAIN! 'what': 'augh!', @@ -92,29 +103,73 @@ def test_fail_unexpected_parameters(self): def test_fail_transaction_wrong_type(self): """ - ``transaction`` is not a TrytesCompatible value. + ``transactions`` contains no TrytesCompatible value. """ self.assertFilterErrors( { - 'transaction': 42, + 'transactions': [42], }, { - 'transaction': [f.Type.CODE_WRONG_TYPE], + 'transactions.0': [f.Type.CODE_WRONG_TYPE], }, ) def test_fail_transaction_not_trytes(self): """ - ``transaction`` contains invalid characters. + ``transactions`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'transactions': [b'not valid; must contain only uppercase and "9"'], + }, + + { + 'transactions.0': [Trytes.CODE_NOT_TRYTES], + }, + ) + + def test_fail_no_list(self): + """ + ``transactions`` has one hash rather than a list of hashes. + """ + self.assertFilterErrors( + { + 'transactions': self.transactions[0], + }, + + { + 'transactions': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_transactions_contents_invalid(self): + """ + ``transactions`` is a non-empty array, but it contains invlaid values. """ self.assertFilterErrors( { - 'transaction': b'not valid; must contain only uppercase and "9"', + 'transactions': [ + b'', + True, + None, + b'not valid transaction hash', + + # A valid tx hash, this should not produce error + TransactionHash(self.transactions[0]), + + 65498731, + b'9' * (TransactionHash.LEN +1), + ], }, { - 'transaction': [Trytes.CODE_NOT_TRYTES], + 'transactions.0': [f.Required.CODE_EMPTY], + 'transactions.1': [f.Type.CODE_WRONG_TYPE], + 'transactions.2': [f.Required.CODE_EMPTY], + 'transactions.3': [Trytes.CODE_NOT_TRYTES], + 'transactions.5': [f.Type.CODE_WRONG_TYPE], + 'transactions.6': [Trytes.CODE_WRONG_FORMAT], }, ) @@ -286,7 +341,7 @@ def test_wireup(self): api = Iota(self.adapter) # Don't need to call with proper args here. - response = api.get_bundles('transaction') + response = api.get_bundles('transactions') self.assertTrue(mocked_command.called) @@ -308,15 +363,45 @@ def test_happy_path(self): 'trytes': [self.spam_trytes], }) - response = self.command(transaction = self.tx_hash) + response = self.command(transactions = [self.tx_hash]) + + self.maxDiff = None + original_bundle = Bundle.from_tryte_strings(self.bundle_trytes) + self.assertListEqual( + response['bundles'][0].as_json_compatible(), + original_bundle.as_json_compatible(), + ) + + def test_happy_path_multiple_bundles(self): + """ + Get two bundles with multiple transactions. + """ + # We will fetch the same two bundle + for _ in range(2): + for txn_trytes in self.bundle_trytes: + self.adapter.seed_response('getTrytes', { + 'trytes': [txn_trytes], + }) + + self.adapter.seed_response('getTrytes', { + 'trytes': [self.spam_trytes], + }) + + response = self.command(transactions = [self.tx_hash, self.tx_hash]) self.maxDiff = None original_bundle = Bundle.from_tryte_strings(self.bundle_trytes) + self.assertListEqual( response['bundles'][0].as_json_compatible(), original_bundle.as_json_compatible(), ) + self.assertListEqual( + response['bundles'][1].as_json_compatible(), + original_bundle.as_json_compatible(), + ) + def test_validator_error(self): """ TraverseBundleCommand returns bundle but it is invalid. @@ -335,4 +420,4 @@ def test_validator_error(self): }) with self.assertRaises(BadApiResponse): - response = self.command(transaction = self.tx_hash) \ No newline at end of file + response = self.command(transactions = [self.tx_hash]) \ No newline at end of file