From 07f344c62872dbff46439bcdc4b05fcf5617e1bd Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Tue, 22 Oct 2019 11:40:03 +0200 Subject: [PATCH 01/20] Implement broadcast_bundle() Api Command As part of the extended api (Iota class), this function takes a tail transaction hash of a bundle and re-broadcasts the entire bundle. It does so by fetching and validating the bundle from the Tangle, and calling broadcast_transactions() core api. --- docs/api.rst | 20 ++ iota/api.py | 26 +++ iota/commands/extended/__init__.py | 1 + iota/commands/extended/broadcast_bundle.py | 51 +++++ .../extended/broadcast_bundle_test.py | 205 ++++++++++++++++++ 5 files changed, 303 insertions(+) create mode 100644 iota/commands/extended/broadcast_bundle.py create mode 100644 test/commands/extended/broadcast_bundle_test.py diff --git a/docs/api.rst b/docs/api.rst index 4c127f0e..bcb3d186 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -37,6 +37,26 @@ This method returns a ``dict`` with the following items: broadcast/stored. Should be the same as the value of the ``trytes`` parameter. +``broadcast_bundle`` +----------------------- + +Re-broadcasts all transactions in a bundle given the tail transaction hash. +It might be useful when transactions did not properly propagate, +particularly in the case of large bundles. + +Parameters +~~~~~~~~~~ + +- ``tail_hash: TransactionHash``: Transaction hash of the tail transaction + of the bundle. + +Return +~~~~~~ + +This method returns a ``dict`` with the following items: + +- ``trytes: List[TransactionTrytes]``: Transaction trytes that were + broadcast. ``find_transaction_objects`` ---------------------------- diff --git a/iota/api.py b/iota/api.py index e5eec635..85bbb64b 100644 --- a/iota/api.py +++ b/iota/api.py @@ -585,6 +585,32 @@ def broadcast_and_store(self, trytes): """ return extended.BroadcastAndStoreCommand(self.adapter)(trytes=trytes) + def broadcast_bundle(self, tail_transaction_hash): + # type (TransactionHash) -> dict + """ + Re-broadcasts all transactions in a bundle given the tail transaction hash. + It might be useful when transactions did not properly propagate, + particularly in the case of large bundles. + + :param tail_transaction_hash: + Tail transaction hash of the bundle. + + :return: + Dict with the following structure::: + + { + 'trytes': List[TransactionTrytes], + List of TransactionTrytes that were broadcast. + } + + References: + + - https://github.com/iotaledger/iota.js/blob/next/api_reference.md#module_core.broadcastBundle + """ + + return extended.BroadcastBundleCommand(self.adapter)(tail_hash=tail_transaction_hash) + + def find_transaction_objects( self, bundles=None, # type: Optional[Iterable[BundleHash]] diff --git a/iota/commands/extended/__init__.py b/iota/commands/extended/__init__.py index efb9ff89..e9e55c02 100644 --- a/iota/commands/extended/__init__.py +++ b/iota/commands/extended/__init__.py @@ -12,6 +12,7 @@ unicode_literals from .broadcast_and_store import * +from .broadcast_bundle import * from .find_transaction_objects import * from .get_account_data import * from .get_bundles import * diff --git a/iota/commands/extended/broadcast_bundle.py b/iota/commands/extended/broadcast_bundle.py new file mode 100644 index 00000000..4cf73a55 --- /dev/null +++ b/iota/commands/extended/broadcast_bundle.py @@ -0,0 +1,51 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import filters as f +from iota.filters import Trytes + +from iota import TransactionTrytes, TransactionHash +from iota.commands.core import \ + BroadcastTransactionsCommand +from iota.commands.extended.get_bundles import GetBundlesCommand +from iota.commands import FilterCommand, RequestFilter + +__all__ = [ + 'BroadcastBundleCommand', +] + + +class BroadcastBundleCommand(FilterCommand): + """ + Executes ``broadcastBundle`` extended API command. + + See :py:meth:`iota.api.Iota.broadcast_bundle` for more info. + """ + command = 'broadcastBundle' + + def get_request_filter(self): + return BroadcastBundleRequestFilter() + + def get_response_filter(self): + # Return value is filtered before hitting us. + pass + + def _execute(self, request): + # Given tail hash, fetches the bundle from the tangle + # and validates it. + # Returns List[List[TransactionTrytes]] + # (outer list has one item in current implementation) + bundle = GetBundlesCommand(self.adapter)(transaction=request['tail_hash']) + BroadcastTransactionsCommand(self.adapter)(trytes=bundle[0]) + return { + 'trytes': bundle[0], + } + # Future: Support multiple bundles in getBundlesCommand, and + # then broadcastBundle can do that as well. + +class BroadcastBundleRequestFilter(RequestFilter): + def __init__(self): + super(BroadcastBundleRequestFilter, self).__init__({ + 'tail_hash': f.Required | Trytes(TransactionHash), + }) \ No newline at end of file diff --git a/test/commands/extended/broadcast_bundle_test.py b/test/commands/extended/broadcast_bundle_test.py new file mode 100644 index 00000000..570b202f --- /dev/null +++ b/test/commands/extended/broadcast_bundle_test.py @@ -0,0 +1,205 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from unittest import TestCase + +import filters as f +from filters.test import BaseFilterTestCase + +from iota import Address, BadApiResponse, Bundle, BundleHash, Fragment, Hash, \ + Iota, Tag, Transaction, TransactionHash, TransactionTrytes, Nonce +from iota.adapter import MockAdapter +from iota.commands.extended.broadcast_bundle import BroadcastBundleCommand +from iota.filters import Trytes + +from six import PY2 + +if PY2: + from mock import MagicMock, patch +else: + from unittest.mock import MagicMock, patch + +# RequestFilterTestCase code reused from get_bundles_test.py +class BroadcastBundleRequestFilterTestCase(BaseFilterTestCase): + filter_type = BroadcastBundleCommand(MockAdapter()).get_request_filter + skip_value_check = True + + def setUp(self): + super(BroadcastBundleRequestFilterTestCase, self).setUp() + + # noinspection SpellCheckingInspection + self.transaction = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999KPZOTR' + 'VDB9GZDJGZSSDCBIX9QOK9PAV9RMDBGDXLDTIZTWQ' + ) + + def test_pass_happy_path(self): + """ + Request is valid. + """ + # Raw trytes are extracted to match the IRI's JSON protocol. + request = { + 'tail_hash': self.transaction, + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, request) + + def test_pass_compatible_types(self): + """ + Request contains values that can be converted to the expected + types. + """ + filter_ = self._filter({ + # Any TrytesCompatible value will work here. + 'tail_hash': TransactionHash(self.transaction), + }) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + + { + 'tail_hash': self.transaction, + }, + ) + + def test_fail_empty(self): + """ + Request is empty. + """ + self.assertFilterErrors( + {}, + + { + 'tail_hash': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + def test_fail_unexpected_parameters(self): + """ + Request contains unexpected parameters. + """ + self.assertFilterErrors( + { + 'tail_hash': TransactionHash(self.transaction), + + # SAY "WHAT" AGAIN! + 'what': 'augh!', + }, + + { + 'what': [f.FilterMapper.CODE_EXTRA_KEY], + }, + ) + + def test_fail_transaction_wrong_type(self): + """ + ``tail_hash`` is not a TrytesCompatible value. + """ + self.assertFilterErrors( + { + 'tail_hash': 42, + }, + + { + 'tail_hash': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_transaction_not_trytes(self): + """ + ``tail_hash`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'tail_hash': b'not valid; must contain only uppercase and "9"', + }, + + { + 'tail_hash': [Trytes.CODE_NOT_TRYTES], + }, + ) + +class BroadcastBundleCommandTestCase(TestCase): + def setUp(self): + super(BroadcastBundleCommandTestCase, self).setUp() + + self.adapter = MockAdapter() + self.command = BroadcastBundleCommand(self.adapter) + + self.tail = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999999999' + ) + + self.trytes = [ + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION1', + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION2' + ] + + self.trytes_dummy = [ + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION3', + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION4' + ] + + def test_wireup(self): + """ + Verify that the command is wired up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).broadcastBundle, + BroadcastBundleCommand, + ) + + def test_happy_path(self): + """ + Test command flow executes as expected. + """ + # Call the command with a tail hash. + # Lets mock away GetBundlesCommand, and we don't do + # BroadcastTransactionsCommand either. + # We could seed a response to our MockAdapter, but then we shall provide + # valid values to pass GetBundlesRequestFilter. Instead we mock away the + # whole command, so no filter is applied. It is safe because it is tested + # elsewhere. + with patch('iota.commands.extended.get_bundles.GetBundlesCommand.__call__', + MagicMock(return_value=[self.trytes])) as mocked_get_bundles: + # We could seed a reponse to our MockAdapter, but then the returned value + # from `GetBundlesCommand` shall be valid to pass + # BroadcastTransactionRequestFilter. + # Anyway, nature loves symmetry and so do we. + with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', + MagicMock(return_value= [])) as mocked_broadcast: + + response = self.command(tail_hash=self.tail) + + self.assertEqual( + response['trytes'], + self.trytes + ) + + def test_happy_path_multiple_bundle(self): + """ + Test if command returns the correct bundle if underlying `get_bundles` + returns multiple bundles. + """ + # Call the command with a tail hash. + # Lets mock away GetBundlesCommand, and we don't do + # BroadcastTransactionsCommand either. + # Note that GetBundlesCommand returns multiple bundles! + with patch('iota.commands.extended.get_bundles.GetBundlesCommand.__call__', + MagicMock(return_value=[self.trytes, self.trytes_dummy]) + ) as mocked_get_bundles: + with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', + MagicMock(return_value= [])) as mocked_broadcast: + + response = self.command(tail_hash=self.tail) + + # Expect only the first bundle + self.assertEqual( + response['trytes'], + self.trytes + ) \ No newline at end of file From 35a274cf4fe9c6b917c698c3c33ae1d3b9bf66fb Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Wed, 23 Oct 2019 12:15:45 +0200 Subject: [PATCH 02/20] Code polish after PR review --- iota/api.py | 3 +-- iota/commands/extended/broadcast_bundle.py | 2 -- test/commands/extended/broadcast_bundle_test.py | 8 ++++---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/iota/api.py b/iota/api.py index 85bbb64b..3e0a55e1 100644 --- a/iota/api.py +++ b/iota/api.py @@ -596,7 +596,7 @@ def broadcast_bundle(self, tail_transaction_hash): Tail transaction hash of the bundle. :return: - Dict with the following structure::: + Dict with the following structure:: { 'trytes': List[TransactionTrytes], @@ -610,7 +610,6 @@ def broadcast_bundle(self, tail_transaction_hash): return extended.BroadcastBundleCommand(self.adapter)(tail_hash=tail_transaction_hash) - def find_transaction_objects( self, bundles=None, # type: Optional[Iterable[BundleHash]] diff --git a/iota/commands/extended/broadcast_bundle.py b/iota/commands/extended/broadcast_bundle.py index 4cf73a55..a4cb8938 100644 --- a/iota/commands/extended/broadcast_bundle.py +++ b/iota/commands/extended/broadcast_bundle.py @@ -41,8 +41,6 @@ def _execute(self, request): return { 'trytes': bundle[0], } - # Future: Support multiple bundles in getBundlesCommand, and - # then broadcastBundle can do that as well. class BroadcastBundleRequestFilter(RequestFilter): def __init__(self): diff --git a/test/commands/extended/broadcast_bundle_test.py b/test/commands/extended/broadcast_bundle_test.py index 570b202f..b6014ac7 100644 --- a/test/commands/extended/broadcast_bundle_test.py +++ b/test/commands/extended/broadcast_bundle_test.py @@ -159,7 +159,7 @@ def test_happy_path(self): Test command flow executes as expected. """ # Call the command with a tail hash. - # Lets mock away GetBundlesCommand, and we don't do + # Let's mock away GetBundlesCommand, and we don't do # BroadcastTransactionsCommand either. # We could seed a response to our MockAdapter, but then we shall provide # valid values to pass GetBundlesRequestFilter. Instead we mock away the @@ -172,7 +172,7 @@ def test_happy_path(self): # BroadcastTransactionRequestFilter. # Anyway, nature loves symmetry and so do we. with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', - MagicMock(return_value= [])) as mocked_broadcast: + MagicMock(return_value=[])) as mocked_broadcast: response = self.command(tail_hash=self.tail) @@ -187,14 +187,14 @@ def test_happy_path_multiple_bundle(self): returns multiple bundles. """ # Call the command with a tail hash. - # Lets mock away GetBundlesCommand, and we don't do + # Let's mock away GetBundlesCommand, and we don't do # BroadcastTransactionsCommand either. # Note that GetBundlesCommand returns multiple bundles! with patch('iota.commands.extended.get_bundles.GetBundlesCommand.__call__', MagicMock(return_value=[self.trytes, self.trytes_dummy]) ) as mocked_get_bundles: with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', - MagicMock(return_value= [])) as mocked_broadcast: + MagicMock(return_value=[])) as mocked_broadcast: response = self.command(tail_hash=self.tail) From aa878fb2993f1e9450e93a70746ac94419679038 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Tue, 29 Oct 2019 17:43:53 +0100 Subject: [PATCH 03/20] Implement is_promotable Extended API Command Checks if a list of tails can be promoted or not by calling checkConsistency core API and performing extra checks on the attachment_timestamp of the transactions fetched from the Tangle. --- docs/api.rst | 35 ++ iota/api.py | 37 ++ iota/commands/extended/__init__.py | 1 + iota/commands/extended/is_promotable.py | 115 ++++++ test/commands/extended/is_promotable_test.py | 407 +++++++++++++++++++ 5 files changed, 595 insertions(+) create mode 100644 iota/commands/extended/is_promotable.py create mode 100644 test/commands/extended/is_promotable_test.py diff --git a/docs/api.rst b/docs/api.rst index bcb3d186..16fe936d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -278,6 +278,41 @@ This method returns a ``dict`` with the following items: - ``bundles: List[Bundle]``: Matching bundles, sorted by tail transaction timestamp. +``is_promotable`` +------------------- + +This extended API function helps you to determine whether a tail transaction +(bundle) is promotable. +Example usage could be to determine if a transaction can be promoted or you +should reattach (``replay_bundle``). + +The method takes a list of tail transaction hashes, calls ``check_consistency`` +to verify consistency. If successful, fetches the transaction trytes from the +Tangle and checks if ``attachment_timestamp`` is within reasonable limits. + +Parameters +~~~~~~~~~~ + +- ``tails: List[TransactionHash]``: Tail transaction hashes to check. + +Return +~~~~~~ + +This method returns a ``dict`` with the following items: + +- ``promotable: bool``: ``True``, if: + + - Tails are consistent. + See `API Reference `_. + - ``attachment_timestamp`` for all transactions are less than current time + and attachement happened no earlier than ``depth`` milestones. + By default, ``depth`` = 6. + + parameter is ``False`` otherwise. + +- ``info: Optional(List[String])``: If ``promotable`` is ``False``, contains information + about the error. + ``is_reattachable`` ------------------- diff --git a/iota/api.py b/iota/api.py index 3e0a55e1..6ea1ec6f 100644 --- a/iota/api.py +++ b/iota/api.py @@ -1002,6 +1002,43 @@ def get_transfers(self, start=0, stop=None, inclusion_states=False): inclusionStates=inclusion_states, ) + def is_promotable( + self, + tails, # type: Iterable[TransactionHash] + ): + # type: (Iterable(TransactionHash)] -> dict + """ + Checks if tail transaction(s) is promotable by calling + :py:meth:`check_consistency` and verifying that `attachmentTimestamp` + is above a lower bound. + Lower bound is calculated based on number of milestones issued + since transaction attachment. + + :param tails: + List of tail transaction hashes. + + :return: + The return type mimics that of :py:meth:`check_consistency`. + Dict with the following structure:: + + { + 'promotable': bool, + If true, all tails are promotable. If false, see `info` + field. + + 'info': Optional(List[String]) + If `promotable` is false, this contains info about what + went wrong. + + } + + References: + - https://github.com/iotaledger/iota.js/blob/next/api_reference.md#module_core.isPromotable + """ + return extended.IsPromotableCommand(self.adapter)( + tails=tails, + ) + def prepare_transfer( self, transfers, # type: Iterable[ProposedTransaction] diff --git a/iota/commands/extended/__init__.py b/iota/commands/extended/__init__.py index e9e55c02..c399a03a 100644 --- a/iota/commands/extended/__init__.py +++ b/iota/commands/extended/__init__.py @@ -21,6 +21,7 @@ from .get_new_addresses import * from .get_transaction_objects import * from .get_transfers import * +from .is_promotable import * from .is_reattachable import * from .prepare_transfer import * from .promote_transaction import * diff --git a/iota/commands/extended/is_promotable.py b/iota/commands/extended/is_promotable.py new file mode 100644 index 00000000..4e3fa2d1 --- /dev/null +++ b/iota/commands/extended/is_promotable.py @@ -0,0 +1,115 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from iota.commands import FilterCommand, RequestFilter +from iota.commands.core import CheckConsistencyCommand, GetTrytesCommand +from iota.transaction import Transaction +from iota import TransactionHash +import filters as f +from iota.filters import Trytes +import time + +__all__ = [ + 'IsPromotableCommand', +] + +MILESTONE_INTERVAL = 2 * 60 * 1000 +""" +Approximate interval in which a milestone is issued. +Unit is in milliseconds, so it is roughly 2 minutes. +""" + +ONE_WAY_DELAY = 1 * 60 * 1000 +""" +Propagation delay of the network (ms). (really-really worst case scenario) +The time needed for the message to propegate from client to edges of +majority network. +""" + +DEPTH = 6 +""" +The number of milestones issued since `attachmentTimestamp`. +""" + +get_current_ms = lambda : int(round(time.time() * 1000)) +""" +Calculate current time in milliseconds. +""" + +class IsPromotableCommand(FilterCommand): + """ + Determines if a tail transaction is promotable. + + See :py:meth:`iota.api.Iota.is_promotable` for more info. + """ + command = 'isPromotable' + + def get_request_filter(self): + return IsPromotableRequestFilter() + + def get_response_filter(self): + pass + + def _execute(self, request): + tails = request['tails'] + + # First, check consistency + # A transaction is consistent, if: + # - The node isn't missing the transaction's branch or trunk transactions + # - The transaction's bundle is valid + # - The transaction's branch and trunk transactions are valid + cc_response = CheckConsistencyCommand(self.adapter)( + tails=tails, + ) + + if not cc_response['state']: + # One or more transactions are inconsistent + return { + 'promotable' : False, + 'info' : cc_response['info'], + } + + transactions = [ + Transaction.from_tryte_string(x) for x in + GetTrytesCommand(self.adapter)(hashes=tails)['trytes'] + ] + + response = { + 'promotable' : True, + 'info' : [], + } + + # Check timestamps + for tx in transactions: + is_within = is_within_depth(tx.attachment_timestamp) + if not is_within: + # Inform the user about what went wrong. + response['info'].append('Transaction {tx_hash} is above max depth.'.format( + tx_hash=tx.hash + )) + # If one tx fails, response is false + response['promotable'] = response['promotable'] and is_within + + # If there are no problems, we don't need 'info' field + if response['promotable']: + del response['info'] + + return response + +class IsPromotableRequestFilter(RequestFilter): + def __init__(self): + super(IsPromotableRequestFilter, self).__init__({ + 'tails': + f.Required | + f.Array | + f.FilterRepeater(f.Required | Trytes(TransactionHash)), + }) + +def is_within_depth(attachment_timestamp, depth=DEPTH): + """ + Checks if `attachment_timestamp` is within limits of `depth`. + """ + now = get_current_ms() + return attachment_timestamp < now and \ + now - attachment_timestamp < depth * MILESTONE_INTERVAL - ONE_WAY_DELAY \ No newline at end of file diff --git a/test/commands/extended/is_promotable_test.py b/test/commands/extended/is_promotable_test.py new file mode 100644 index 00000000..04961b38 --- /dev/null +++ b/test/commands/extended/is_promotable_test.py @@ -0,0 +1,407 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from unittest import TestCase + +import filters as f +from filters.test import BaseFilterTestCase + +from iota import Iota, TransactionHash, TryteString, TransactionTrytes, \ + Transaction +from iota.adapter import MockAdapter +from iota.commands.extended.is_promotable import IsPromotableCommand, \ + get_current_ms, is_within_depth +from iota.filters import Trytes +from test import mock + +class IsPromotableRequestFilterTestCase(BaseFilterTestCase): + filter_type = IsPromotableCommand(MockAdapter()).get_request_filter + skip_value_check = True + + # noinspection SpellCheckingInspection + def setUp(self): + super(IsPromotableRequestFilterTestCase, self).setUp() + + self.hash1 = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999DXSCAD' + 'YBVDCTTBLHFYQATFZPYPCBG9FOUKIGMYIGLHM9NEZ' + ) + + self.hash2 = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999EMFYSM' + 'HWODIAPUTTFDLQRLYIDAUIPJXXEXZZSBVKZEBWGAN' + ) + + def test_pass_happy_path(self): + """ + Request is valid. + """ + request = { + # Raw trytes are extracted to match the IRI's JSON protocol. + 'tails': [self.hash1, self.hash2], + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, request) + + def test_pass_compatible_types(self): + """ + Request contains values that can be converted to the expected + types. + """ + filter_ = self._filter({ + 'tails': [ + # Any TrytesCompatible value can be used here. + TransactionHash(self.hash1), + bytearray(self.hash2.encode('ascii')), + ], + }) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + + { + # Raw trytes are extracted to match the IRI's JSON protocol. + 'tails': [self.hash1, self.hash2], + }, + ) + + def test_fail_empty(self): + """ + Request is empty. + """ + self.assertFilterErrors( + {}, + + { + 'tails': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + def test_fail_unexpected_parameters(self): + """ + Request contains unexpected parameters. + """ + self.assertFilterErrors( + { + 'tails': [TransactionHash(self.hash1)], + 'foo': 'bar', + }, + + { + 'foo': [f.FilterMapper.CODE_EXTRA_KEY], + }, + ) + + def test_fail_tails_null(self): + """ + ``tails`` is null. + """ + self.assertFilterErrors( + { + 'tails': None, + }, + + { + 'tails': [f.Required.CODE_EMPTY], + }, + ) + + def test_fail_tails_wrong_type(self): + """ + ``tails`` is not an array. + """ + self.assertFilterErrors( + { + # It's gotta be an array, even if there's only one hash. + 'tails': TransactionHash(self.hash1), + }, + + { + 'tails': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_tails_empty(self): + """ + ``tails`` is an array, but it is empty. + """ + self.assertFilterErrors( + { + 'tails': [], + }, + + { + 'tails': [f.Required.CODE_EMPTY], + }, + ) + + def test_fail_tails_contents_invalid(self): + """ + ``tails`` is a non-empty array, but it contains invalid values. + """ + self.assertFilterErrors( + { + 'tails': [ + b'', + True, + None, + b'not valid trytes', + + # This is actually valid; I just added it to make sure the + # filter isn't cheating! + TryteString(self.hash1), + + 2130706433, + b'9' * 82, + ], + }, + + { + 'tails.0': [f.Required.CODE_EMPTY], + 'tails.1': [f.Type.CODE_WRONG_TYPE], + 'tails.2': [f.Required.CODE_EMPTY], + 'tails.3': [Trytes.CODE_NOT_TRYTES], + 'tails.5': [f.Type.CODE_WRONG_TYPE], + 'tails.6': [Trytes.CODE_WRONG_FORMAT], + }, + ) + + +class IsPromotableCommandTestCase(TestCase): + def setUp(self): + super(IsPromotableCommandTestCase, self).setUp() + + self.adapter = MockAdapter() + self.command = IsPromotableCommand(self.adapter) + + # Define some tryte sequences that we can re-use across tests. + self.trytes1 = TransactionTrytes( + 'CCGCVADBEACCWCXCGDEAXCGDEAPCEAHDTCGDHDEAHDFDPCBDGDPCRCHDXCCDBDE' + 'ACDBD9DMDSA9999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999ETTEXDKDEUALTLRJVX' + 'RHCPRJDLGPJCEQBJOMOAGBZWZCWLNUEWHAUSYJMYPEZPYNBTPSPGUIPQ9VOUNQ9' + '999999999999999999999999999JVPROMOTABLETEST99999999999USHRPBD99' + '999999999999999999XFVLEXEJPTYI9TUA9ULFNHXBGDUCOEPDIBKSZFXEBO9HF' + 'EGLENBCOVKHZ99IWZVCVSTUGKTIBEOVFBJPCDYHBDEIIBLHRVQX9KVVRTUIQMOF' + 'XUUETRIQCCCLSMVREZSNEXLIZCIUYIYRBJIBOKNJCQAJTAHGNZ9999DYHBDEIIB' + 'LHRVQX9KVVRTUIQMOFXUUETRIQCCCLSMVREZSNEXLIZCIUYIYRBJIBOKNJCQAJT' + 'AHGNZ9999ISPROMOTABLETEST9999999999999BFQOIOF999999999MMMMMMMMM' + 'EL999999999AG99999999999999' + ) + + self.hash1 = TransactionHash( + 'MHNBILKFU9CADOPNWSFYOMILGKJAHEU9GSSOYUEAPBGOOLAIKGBYSACXMFQRJZE' + 'PBSHI9SDKMBRK99999' + ) + + self.trytes2 = TransactionTrytes( + 'CCGCVADBEACCWCXCGDEAXCGDEAPCEAHDTCGDHDEAHDFDPCBDGDPCRCHDXCCDBDEA' + 'CDBD9DMDSA999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '9999999999999999999999999999999999999999999999999999999999999999' + '99999999999ETTEXDKDEUALTLRJVXRHCPRJDLGPJCEQBJOMOAGBZWZCWLNUEWHAU' + 'SYJMYPEZPYNBTPSPGUIPQ9VOUNQ9999999999999999999999999999JVPROMOTA' + 'BLETEST99999999999USHRPBD99999999999999999999XFVLEXEJPTYI9TUA9UL' + 'FNHXBGDUCOEPDIBKSZFXEBO9HFEGLENBCOVKHZ99IWZVCVSTUGKTIBEOVFBJPCDA' + 'WCMHRLDQPBBGISNENMIXOGGYSRYXGAFEJC9FOLXLYIQVUHFCMVRPBIEAXDUYYPYN' + 'EZPHH9KB9HZ9999DAWCMHRLDQPBBGISNENMIXOGGYSRYXGAFEJC9FOLXLYIQVUHF' + 'CMVRPBIEAXDUYYPYNEZPHH9KB9HZ9999ISPROMOTABLETEST99999999999IOCFQ' + 'OIOF999999999MMMMMMMMMCAA9999999UYA99999999999999' + ) + + self.hash2 = TransactionHash( + 'FLNPRAOEYMBIXZBBFMQGCEWLRKTZTMWWTVUQRNUNMZR9EMVKETRMWHRMBFWHJHX' + 'ZOIMUWZALX9IVZ9999' + ) + + def test_wireup(self): + """ + Verify that the command is wired up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).isPromotable, + IsPromotableCommand, + ) + + def test_happy_path(self): + """ + Successfully checking promotability. + """ + # To pass timestamp check, we need a timestamp that defines + # a moment no earlier than 660s from the moment the function + # is called during test. To do this, we cheat a bit: + tx = Transaction.from_tryte_string(self.trytes1) + tx.attachment_timestamp = get_current_ms() + # self.trytes1 becomes invalid transaction since we modified + # attachment_timestamp, but it doesn't matter here + self.trytes1 = tx.as_tryte_string() + # The same for self.trytes2 + tx = Transaction.from_tryte_string(self.trytes2) + tx.attachment_timestamp = get_current_ms() + self.trytes2 = tx.as_tryte_string() + + self.adapter.seed_response('checkConsistency', { + 'state': True, + }) + self.adapter.seed_response('getTrytes', { + 'trytes': [self.trytes1, self.trytes2] + }) + + response = self.command(tails=[self.hash1, self.hash2]) + + self.assertDictEqual( + response, + + { + 'promotable': True, + } + ) + + def test_not_consistent(self): + """ + One of the tails is not consistent. + """ + + self.adapter.seed_response('checkConsistency', { + 'state': False, + 'info': 'Oops, something went wrong.', + }) + + # No need for mokcing `getTrytes` becasue we should not + # reach that part + + response = self.command(tails=[self.hash1, self.hash2]) + + self.assertDictEqual( + response, + + { + 'promotable': False, + 'info': 'Oops, something went wrong.', + } + ) + + def test_one_timestamp_invalid(self): + """ + Test invalid timestamp in one of the transactions. + """ + # Note that self.trytes2 will have the original and + # therefore invalid (too old) timestamp + tx = Transaction.from_tryte_string(self.trytes1) + tx.attachment_timestamp = get_current_ms() + self.trytes1 = tx.as_tryte_string() + + self.adapter.seed_response('checkConsistency', { + 'state': True, + }) + self.adapter.seed_response('getTrytes', { + 'trytes': [self.trytes1, self.trytes2] + }) + + response = self.command(tails=[self.hash1, self.hash2]) + + self.assertDictEqual( + response, + + { + 'promotable': False, + 'info': ['Transaction {tx_hash} is above max depth.'.format( + tx_hash=self.hash2 + )], + } + ) + + def test_is_within_depth(self): + """ + Test ``is_within_depth`` helper method. + """ + # Timestamp is too old (depth=3) + old_timestamp = get_current_ms() - 660000 + + self.assertEqual( + is_within_depth(old_timestamp), + False + ) + + # Timestamp points to the future + future_timestamp = get_current_ms() + 500000 + + self.assertEqual( + is_within_depth(future_timestamp), + False + ) + + # Timestamp is valid (one second 'old') + timestamp = get_current_ms() - 1000 + + self.assertEqual( + is_within_depth(timestamp), + True + ) \ No newline at end of file From 2cacc370ba37c57cfe849b1c8cc3dab178b77c71 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Wed, 30 Oct 2019 17:22:46 +0100 Subject: [PATCH 04/20] Refactor traverse_bundle into Extended Api Motivation: Provide the same api calls as in the other iota libs. The difference between get_bundles and traverse_bundle is subtle: get_bundles validates the fetched bundle. Moved previous test cases related to traverse_bundle into new test file, refactored tests for get_bundles. --- docs/api.rst | 19 + iota/api.py | 27 + iota/commands/extended/__init__.py | 1 + iota/commands/extended/get_bundles.py | 72 +- iota/commands/extended/traverse_bundle.py | 108 +++ test/commands/extended/get_bundles_test.py | 717 ++++++++---------- .../commands/extended/traverse_bundle_test.py | 440 +++++++++++ 7 files changed, 902 insertions(+), 482 deletions(-) create mode 100644 iota/commands/extended/traverse_bundle.py create mode 100644 test/commands/extended/traverse_bundle_test.py diff --git a/docs/api.rst b/docs/api.rst index bcb3d186..d73abbdd 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -429,3 +429,22 @@ This method returns a ``dict`` with the following items: - ``trytes: List[TransactionTrytes]``: Raw trytes that were published to the Tangle. + +``traverse_bundle`` +------------------- + +Given a tail ``TransactionHash``, returns the bundle(s) associated with it. +Unlike ``get_bundles``, this command does not validate the fetched bundle(s). + +Parameters +~~~~~~~~~~ + +- ``tail_hash: TransactionHash``: Hash of a tail transaction. + +Return +~~~~~~ + +This method returns a ``dict`` with the following items: + +- ``bundles: List[Bundle]``: List of matching bundles. Note that this + value is always a list, even if only one bundle was found. diff --git a/iota/api.py b/iota/api.py index 3e0a55e1..567b3dbe 100644 --- a/iota/api.py +++ b/iota/api.py @@ -1292,3 +1292,30 @@ def is_reattachable(self, addresses): return extended.IsReattachableCommand(self.adapter)( addresses=addresses ) + + def traverse_bundle(self, tail_hash): + # type: (TransactionHash) -> dict + """ + Fetches and traverses a bundle from the Tangle given a tail transaction + hash. + Recursively traverse the Tangle, collecting transactions until + we hit a new bundle. + + This method is (usually) faster than ``findTransactions``, and + it ensures we don't collect transactions from replayed bundles. + + :param tail_hash: + Tail transaction hash of the bundle. + + :return: + Dict with the following structure:: + + { + 'bundle': List[Bundle], + List of matching bundles. Note that this value is + always a list, even if only one bundle was found. + } + """ + return extended.TraverseBundleCommand(self.adapter)( + transaction=tail_hash + ) diff --git a/iota/commands/extended/__init__.py b/iota/commands/extended/__init__.py index e9e55c02..d1c5c115 100644 --- a/iota/commands/extended/__init__.py +++ b/iota/commands/extended/__init__.py @@ -27,3 +27,4 @@ from .replay_bundle import * from .send_transfer import * from .send_trytes import * +from .traverse_bundle import * diff --git a/iota/commands/extended/get_bundles.py b/iota/commands/extended/get_bundles.py index bec1efcb..ac2575a8 100644 --- a/iota/commands/extended/get_bundles.py +++ b/iota/commands/extended/get_bundles.py @@ -2,14 +2,11 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from typing import List, Optional - import filters as f -from iota import BadApiResponse, Bundle, BundleHash, Transaction, \ - TransactionHash, TryteString +from iota import BadApiResponse, TransactionHash from iota.commands import FilterCommand, RequestFilter -from iota.commands.core.get_trytes import GetTrytesCommand +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 @@ -36,7 +33,10 @@ def get_response_filter(self): def _execute(self, request): transaction_hash = request['transaction'] # type: TransactionHash - bundle = Bundle(self._traverse_bundle(transaction_hash)) + bundle = TraverseBundleCommand(self.adapter)( + transaction=transaction_hash + )['bundles'][0] # Currently 1 bundle only + validator = BundleValidator(bundle) if not validator.is_valid(): @@ -58,66 +58,6 @@ def _execute(self, request): 'bundles': [bundle], } - def _traverse_bundle(self, txn_hash, target_bundle_hash=None): - # type: (TransactionHash, Optional[BundleHash]) -> List[Transaction] - """ - Recursively traverse the Tangle, collecting transactions until - we hit a new bundle. - - This method is (usually) faster than ``findTransactions``, and - it ensures we don't collect transactions from replayed bundles. - """ - trytes = ( - GetTrytesCommand(self.adapter)(hashes=[txn_hash])['trytes'] - ) # type: List[TryteString] - - if not trytes: - raise with_context( - exc=BadApiResponse( - 'Bundle transactions not visible ' - '(``exc.context`` has more info).', - ), - - context={ - 'transaction_hash': txn_hash, - 'target_bundle_hash': target_bundle_hash, - }, - ) - - transaction = Transaction.from_tryte_string(trytes[0]) - - if (not target_bundle_hash) and transaction.current_index: - raise with_context( - exc=BadApiResponse( - '``_traverse_bundle`` started with a non-tail transaction ' - '(``exc.context`` has more info).', - ), - - context={ - 'transaction_object': transaction, - 'target_bundle_hash': target_bundle_hash, - }, - ) - - if target_bundle_hash: - if target_bundle_hash != transaction.bundle_hash: - # We've hit a different bundle; we can stop now. - return [] - else: - target_bundle_hash = transaction.bundle_hash - - if transaction.current_index == transaction.last_index == 0: - # Bundle only has one transaction. - return [transaction] - - # Recursively follow the trunk transaction, to fetch the next - # transaction in the bundle. - return [transaction] + self._traverse_bundle( - txn_hash=transaction.trunk_transaction_hash, - target_bundle_hash=target_bundle_hash - ) - - class GetBundlesRequestFilter(RequestFilter): def __init__(self): super(GetBundlesRequestFilter, self).__init__({ diff --git a/iota/commands/extended/traverse_bundle.py b/iota/commands/extended/traverse_bundle.py new file mode 100644 index 00000000..d81196db --- /dev/null +++ b/iota/commands/extended/traverse_bundle.py @@ -0,0 +1,108 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from typing import List, Optional + +import filters as f + +from iota import BadApiResponse, BundleHash, Transaction, \ + TransactionHash, TryteString, Bundle +from iota.commands import FilterCommand, RequestFilter +from iota.commands.core.get_trytes import GetTrytesCommand +from iota.exceptions import with_context +from iota.filters import Trytes + +__all__ = [ + 'TraverseBundleCommand', +] + + +class TraverseBundleCommand(FilterCommand): + """ + Executes ``traverseBundle`` extended API command. + + See :py:meth:`iota.api.Iota.traverse_bundle` for more info. + """ + command = 'traverseBundle' + + def get_request_filter(self): + return TraverseBundleRequestFilter() + + def get_response_filter(self): + pass + + def _execute(self, request): + txn_hash = request['transaction'] # type: TransactionHash + + bundle = Bundle(self._traverse_bundle(txn_hash, None)) + + # No bundle validation + + return { + 'bundles' : [bundle] + } + + def _traverse_bundle(self, txn_hash, target_bundle_hash): + """ + Recursively traverse the Tangle, collecting transactions until + we hit a new bundle. + + This method is (usually) faster than ``findTransactions``, and + it ensures we don't collect transactions from replayed bundles. + """ + trytes = ( + GetTrytesCommand(self.adapter)(hashes=[txn_hash])['trytes'] + ) # type: List[TryteString] + + if not trytes: + raise with_context( + exc=BadApiResponse( + 'Bundle transactions not visible ' + '(``exc.context`` has more info).', + ), + + context={ + 'transaction_hash': txn_hash, + 'target_bundle_hash': target_bundle_hash, + }, + ) + + transaction = Transaction.from_tryte_string(trytes[0]) + + if (not target_bundle_hash) and transaction.current_index: + raise with_context( + exc=BadApiResponse( + '``_traverse_bundle`` started with a non-tail transaction ' + '(``exc.context`` has more info).', + ), + + context={ + 'transaction_object': transaction, + 'target_bundle_hash': target_bundle_hash, + }, + ) + + if target_bundle_hash: + if target_bundle_hash != transaction.bundle_hash: + # We've hit a different bundle; we can stop now. + return [] + else: + target_bundle_hash = transaction.bundle_hash + + if transaction.current_index == transaction.last_index == 0: + # Bundle only has one transaction. + return [transaction] + + # Recursively follow the trunk transaction, to fetch the next + # transaction in the bundle. + return [transaction] + self._traverse_bundle( + transaction.trunk_transaction_hash, + target_bundle_hash + ) + +class TraverseBundleRequestFilter(RequestFilter): + def __init__(self): + super(TraverseBundleRequestFilter, self).__init__({ + 'transaction': f.Required | Trytes(TransactionHash), + }) diff --git a/test/commands/extended/get_bundles_test.py b/test/commands/extended/get_bundles_test.py index 633dee97..2a1de500 100644 --- a/test/commands/extended/get_bundles_test.py +++ b/test/commands/extended/get_bundles_test.py @@ -1,439 +1,324 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, \ - unicode_literals + unicode_literals from unittest import TestCase import filters as f from filters.test import BaseFilterTestCase -from iota import Address, BadApiResponse, Bundle, BundleHash, Fragment, Hash, \ - Iota, Tag, Transaction, TransactionHash, TransactionTrytes, Nonce +from iota import Address, BadApiResponse, Bundle, \ + Iota, TransactionHash, TransactionTrytes from iota.adapter import MockAdapter from iota.commands.extended.get_bundles import GetBundlesCommand from iota.filters import Trytes class GetBundlesRequestFilterTestCase(BaseFilterTestCase): - filter_type = GetBundlesCommand(MockAdapter()).get_request_filter - skip_value_check = True - - def setUp(self): - super(GetBundlesRequestFilterTestCase, self).setUp() - - # noinspection SpellCheckingInspection - self.transaction = ( - 'TESTVALUE9DONTUSEINPRODUCTION99999KPZOTR' - 'VDB9GZDJGZSSDCBIX9QOK9PAV9RMDBGDXLDTIZTWQ' - ) - - def test_pass_happy_path(self): - """ - Request is valid. - """ - # Raw trytes are extracted to match the IRI's JSON protocol. - request = { - 'transaction': self.transaction, - } - - filter_ = self._filter(request) - - self.assertFilterPasses(filter_) - self.assertDictEqual(filter_.cleaned_data, request) - - def test_pass_compatible_types(self): - """ - Request contains values that can be converted to the expected - types. - """ - filter_ = self._filter({ - # Any TrytesCompatible value will work here. - 'transaction': TransactionHash(self.transaction), - }) - - self.assertFilterPasses(filter_) - self.assertDictEqual( - filter_.cleaned_data, - - { - 'transaction': self.transaction, - }, - ) - - def test_fail_empty(self): - """ - Request is empty. - """ - self.assertFilterErrors( - {}, - - { - 'transaction': [f.FilterMapper.CODE_MISSING_KEY], - }, - ) - - def test_fail_unexpected_parameters(self): - """ - Request contains unexpected parameters. - """ - self.assertFilterErrors( - { - 'transaction': TransactionHash(self.transaction), - - # SAY "WHAT" AGAIN! - 'what': 'augh!', - }, - - { - 'what': [f.FilterMapper.CODE_EXTRA_KEY], - }, - ) - - def test_fail_transaction_wrong_type(self): - """ - ``transaction`` is not a TrytesCompatible value. - """ - self.assertFilterErrors( - { - 'transaction': 42, - }, - - { - 'transaction': [f.Type.CODE_WRONG_TYPE], - }, - ) - - def test_fail_transaction_not_trytes(self): - """ - ``transaction`` contains invalid characters. - """ - self.assertFilterErrors( - { - 'transaction': b'not valid; must contain only uppercase and "9"', - }, - - { - 'transaction': [Trytes.CODE_NOT_TRYTES], - }, - ) - - -# noinspection SpellCheckingInspection + filter_type = GetBundlesCommand(MockAdapter()).get_request_filter + skip_value_check = True + + def setUp(self): + super(GetBundlesRequestFilterTestCase, self).setUp() + + # noinspection SpellCheckingInspection + self.transaction = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999KPZOTR' + 'VDB9GZDJGZSSDCBIX9QOK9PAV9RMDBGDXLDTIZTWQ' + ) + + def test_pass_happy_path(self): + """ + Request is valid. + """ + # Raw trytes are extracted to match the IRI's JSON protocol. + request = { + 'transaction': self.transaction, + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, request) + + def test_pass_compatible_types(self): + """ + Request contains values that can be converted to the expected + types. + """ + filter_ = self._filter({ + # Any TrytesCompatible value will work here. + 'transaction': TransactionHash(self.transaction), + }) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + + { + 'transaction': self.transaction, + }, + ) + + def test_fail_empty(self): + """ + Request is empty. + """ + self.assertFilterErrors( + {}, + + { + 'transaction': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + def test_fail_unexpected_parameters(self): + """ + Request contains unexpected parameters. + """ + self.assertFilterErrors( + { + 'transaction': TransactionHash(self.transaction), + + # SAY "WHAT" AGAIN! + 'what': 'augh!', + }, + + { + 'what': [f.FilterMapper.CODE_EXTRA_KEY], + }, + ) + + def test_fail_transaction_wrong_type(self): + """ + ``transaction`` is not a TrytesCompatible value. + """ + self.assertFilterErrors( + { + 'transaction': 42, + }, + + { + 'transaction': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_transaction_not_trytes(self): + """ + ``transaction`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'transaction': b'not valid; must contain only uppercase and "9"', + }, + + { + 'transaction': [Trytes.CODE_NOT_TRYTES], + }, + ) + +# Tests related to TraverseBundleCommand are moved to +# iota/test/commands/extended/traverse_bundle_test.py +# Here we only include one 'happy path' test, and focus on bundle validator +# problems. class GetBundlesCommandTestCase(TestCase): - def setUp(self): - super(GetBundlesCommandTestCase, self).setUp() - - self.adapter = MockAdapter() - self.command = GetBundlesCommand(self.adapter) - - def test_wireup(self): - """ - Verifies that the command is wired up correctly. - """ - self.assertIsInstance( - Iota(self.adapter).getBundles, - GetBundlesCommand, - ) - - def test_single_transaction(self): - """ - Getting a bundle that contains a single transaction. - """ - transaction =\ - Transaction( - current_index = 0, - last_index = 0, - tag = Tag(b''), - timestamp = 1484960990, - value = 0, - attachment_timestamp = 1484960990, - attachment_timestamp_lower_bound = 12, - attachment_timestamp_upper_bound = 0, - - # These values are not relevant for 0-value transactions. - nonce = Nonce(b''), - signature_message_fragment = Fragment(b''), - - # This value is computed automatically, so it has to be real. - hash_ = - TransactionHash( - b'XPJIYZWPF9LBCYZPNBFARDRCSUGJGF9TWZT9K9PX' - b'VYDFPZOZBGXUCKLTJEUCFBEKQQ9VCSQVQDMMJQAY9', - ), + def setUp(self): + super(GetBundlesCommandTestCase, self).setUp() - address = - Address( - b'TESTVALUE9DONTUSEINPRODUCTION99999OCSGVF' - b'IBQA99KGTCPCZ9NHR9VGLGADDDIEGGPCGBDEDDTBC', - ), + self.adapter = MockAdapter() + self.command = GetBundlesCommand(self.adapter) - bundle_hash = - BundleHash( - b'TESTVALUE9DONTUSEINPRODUCTION99999DIOAZD' - b'M9AIUHXGVGBC9EMGI9SBVBAIXCBFJ9EELCPDRAD9U', - ), + # Tail transaction hash + self.tx_hash = TransactionHash( + 'TOYJPHKMLQNDVLDHDILARUJCCIUMQBLUSWPCTIVA' + 'DRXICGYDGSVPXFTILFFGAPICYHGGJ9OHXINFX9999' + ) - branch_transaction_hash = - TransactionHash( - b'TESTVALUE9DONTUSEINPRODUCTION99999BBCEDI' - b'ZHUDWBYDJEXHHAKDOCKEKDFIMB9AMCLFW9NBDEOFV', + self.bundle_trytes = [ + # Order is important if we don't convert to bundle representation. + # Tail transaction should be the first. + TransactionTrytes( + 'NBTCPCFDEACCPCBDVC9DTCQAJ9RBTC9D9DCDQAEAKDCDFD9DSCFAJ9VBCDJDTCQAJ9' + 'ZBMDYBCCKB99999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999SYRABNN9JD9PNDL' + 'IKUNCECUELTOHNLFMVD99999999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' + 'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXFSEWUNJOEGNU' + 'I9QOCRFMYSIFAZLJHKZBPQZZYFG9ORYCRDX9TOMJPFCRB9R9KPUUGFPVOWYXFIWEW9' + '999BGUEHHGAIWWQBCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJL' + 'EDAMYVRGABAWBY9999SYRABNN9JD9PNDLIKUNCECUELTOQZPSBDILVHJQVCEOICFAD' + 'YKZVGMOAXJRQNTCKMHGTAUMPGJJMX9LNF' ), - trunk_transaction_hash = - TransactionHash( - b'TESTVALUE9DONTUSEINPRODUCTION999999ARAYA' - b'MHCB9DCFEIWEWDLBCDN9LCCBQBKGDDAECFIAAGDAS', + TransactionTrytes( + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999WUQXEGBVIECGIWO9IGSYKWWPYCIVUJJGSJPWGIAFJPYSF9NSQOHWAHS9P' + '9PWQHOBXNNQIF9IRHVQXKPZW999999999999999999999999999XZUIENOTTBKJMDP' + 'RXWGQYG9PWGTHNLFMVD99A99999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' + 'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXBGUEHHGAIWWQ' + 'BCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJLEDAMYVRGABAWBY9' + '999MYIYBTGIOQYYZFJBLIAWMPSZEFFTXUZPCDIXSLLQDQSFYGQSQOGSPKCZNLVSZ9L' + 'MCUWVNGEN9EJEW9999XZUIENOTTBKJMDPRXWGQYG9PWGTXUO9AXMP9FLMDRMADLRPW' + 'CZCJBROYCDRJMYU9HDYJM9NDBFUPIZVTR' ), + ] + + # Add a spam tx. When this is returned, traverse_bundle knows it hit a + # different bundle and should stop. + self.spam_trytes = TransactionTrytes( + 'SPAMSPAMSPAM999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999JECDITWO9999999' + '999999999999ONLFMVD99999999999999999999VVCHSQSRVFKSBONDWB9EAQEMQOY' + 'YRBIZHTBJLYNAVDHZPUZAZ9LYHXWKBEJ9IPR9FAMFLT9EEOHVYWUPRHHSRCILCLWFD' + 'GBYBFFOKMCSAPVD9VGZZRRGBLGMZMXD9RMZQDBLMGN9BATWZGULRBCYQEIKIRBPHC9' + '999KTLTRSYOWBD9HVNP9GCUABARNGMYXUZKXWRPGOPETZLKYYC9Z9EYXIWVARUBMBM' + 'BPXGORN9WPBLY99999ZRBVQWULRFXDNDYZKRKIXPZQT9JJJH9FZU9PVWZJWLXBPODP' + 'EHMKTTAGEPLPHUQCZNLDSHERONOMHJCOI' + ) + + def test_wireup(self): + """ + Verifies that the command is wired up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).getBundles, + GetBundlesCommand, + ) + + def test_happy_path(self): + """ + Get a bundle with multiple transactions. + """ + 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(transaction = 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.adapter.seed_response('getTrytes', { - 'trytes': [transaction.as_tryte_string()], - }) - - response = self.command(transaction=transaction.hash) - - bundle = response['bundles'][0] # type: Bundle - self.assertEqual(len(bundle), 1) - - self.maxDiff = None - self.assertDictEqual( - bundle[0].as_json_compatible(), - transaction.as_json_compatible(), - ) - - def test_multiple_transactions(self): - """ - Getting a bundle that contains multiple transactions. - """ - bundle = Bundle.from_tryte_strings([ - TransactionTrytes( - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999WUQXEGBVIECGIWO9IGSYKWWPYCIVUJJGSJPWGIAFJPYSF9NSQOHWAHS9P' - b'9PWQHOBXNNQIF9IRHVQXKPZW999999999999999999999999999XZUIENOTTBKJMDP' - b'RXWGQYG9PWGTHNLFMVD99A99999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' - b'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXBGUEHHGAIWWQ' - b'BCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJLEDAMYVRGABAWBY9' - b'999MYIYBTGIOQYYZFJBLIAWMPSZEFFTXUZPCDIXSLLQDQSFYGQSQOGSPKCZNLVSZ9L' - b'MCUWVNGEN9EJEW9999XZUIENOTTBKJMDPRXWGQYG9PWGTXUO9AXMP9FLMDRMADLRPW' - b'CZCJBROYCDRJMYU9HDYJM9NDBFUPIZVTR' - ), - - # Well, it was bound to happen sooner or later... the ASCII - # representation of this tryte sequence contains a very naughty - # phrase. But I don't feel like doing another POW, so... enjoy. - TransactionTrytes( - b'NBTCPCFDEACCPCBDVC9DTCQAJ9RBTC9D9DCDQAEAKDCDFD9DSCFAJ9VBCDJDTCQAJ9' - b'ZBMDYBCCKB99999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999SYRABNN9JD9PNDL' - b'IKUNCECUELTOHNLFMVD99999999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' - b'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXFSEWUNJOEGNU' - b'I9QOCRFMYSIFAZLJHKZBPQZZYFG9ORYCRDX9TOMJPFCRB9R9KPUUGFPVOWYXFIWEW9' - b'999BGUEHHGAIWWQBCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJL' - b'EDAMYVRGABAWBY9999SYRABNN9JD9PNDLIKUNCECUELTOQZPSBDILVHJQVCEOICFAD' - b'YKZVGMOAXJRQNTCKMHGTAUMPGJJMX9LNF' - ), - ]) - - for txn in bundle: - self.adapter.seed_response('getTrytes', { - 'trytes': [txn.as_tryte_string()], - }) - - self.adapter.seed_response('getTrytes', { - 'trytes': [ - 'SPAMSPAMSPAM999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999999999999999999' - '999999999999999999999999999999999999999999999999999JECDITWO9999999' - '999999999999ONLFMVD99999999999999999999VVCHSQSRVFKSBONDWB9EAQEMQOY' - 'YRBIZHTBJLYNAVDHZPUZAZ9LYHXWKBEJ9IPR9FAMFLT9EEOHVYWUPRHHSRCILCLWFD' - 'GBYBFFOKMCSAPVD9VGZZRRGBLGMZMXD9RMZQDBLMGN9BATWZGULRBCYQEIKIRBPHC9' - '999KTLTRSYOWBD9HVNP9GCUABARNGMYXUZKXWRPGOPETZLKYYC9Z9EYXIWVARUBMBM' - 'BPXGORN9WPBLY99999ZRBVQWULRFXDNDYZKRKIXPZQT9JJJH9FZU9PVWZJWLXBPODP' - 'EHMKTTAGEPLPHUQCZNLDSHERONOMHJCOI' - ], - }) - - response = self.command( - transaction = - TransactionHash( - b'TOYJPHKMLQNDVLDHDILARUJCCIUMQBLUSWPCTIVA' - b'DRXICGYDGSVPXFTILFFGAPICYHGGJ9OHXINFX9999' - ), - ) - self.maxDiff = None - self.assertListEqual( - response['bundles'][0].as_json_compatible(), - bundle.as_json_compatible(), - ) - - def test_non_tail_transaction(self): - """ - Trying to get a bundle for a non-tail transaction. - - This is not valid; you have to start with a tail transaction. - """ - self.adapter.seed_response('getTrytes', { - 'trytes': [ - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999WUQXEGBVIECGIWO9IGSYKWWPYCIVUJJGSJPWGIAFJPYSF9NSQOHWAHS9P' - b'9PWQHOBXNNQIF9IRHVQXKPZW999999999999999999999999999999999999999999' - b'999999999999HNLFMVD99A99999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' - b'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXBGUEHHGAIWWQ' - b'BCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJLEDAMYVRGABAWBY9' - b'999MYIYBTGIOQYYZFJBLIAWMPSZEFFTXUZPCDIXSLLQDQSFYGQSQOGSPKCZNLVSZ9L' - b'MCUWVNGEN9EJEW9999XZUIENOTTBKJMDPRXWGQYG9PWGTXUO9AXMP9FLMDRMADLRPW' - b'CZCJBROYCDRJMYU9HDYJM9NDBFUPIZVTR' - ], - }) - - with self.assertRaises(BadApiResponse): - self.command( - transaction = - TransactionHash( - b'FSEWUNJOEGNUI9QOCRFMYSIFAZLJHKZBPQZZYFG9' - b'ORYCRDX9TOMJPFCRB9R9KPUUGFPVOWYXFIWEW9999' - ), - ) - - def test_missing_transaction(self): - """ - Unable to find the requested transaction. - """ - self.adapter.seed_response('getTrytes', {'trytes': []}) - - with self.assertRaises(BadApiResponse): - self.command( - transaction = - TransactionHash( - b'FSEWUNJOEGNUI9QOCRFMYSIFAZLJHKZBPQZZYFG9' - b'ORYCRDX9TOMJPFCRB9R9KPUUGFPVOWYXFIWEW9999' - ), - ) + def test_validator_error(self): + """ + TraverseBundleCommand returns bundle but it is invalid. + """ + # Make the returned bundle invalid + bundle = Bundle.from_tryte_strings(self.bundle_trytes) + bundle.transactions[0].value = 999 # Unbalanced bundle + + for txn in bundle.transactions: + self.adapter.seed_response('getTrytes', { + 'trytes': [txn.as_tryte_string()], + }) + + self.adapter.seed_response('getTrytes', { + 'trytes': [self.spam_trytes], + }) + + with self.assertRaises(BadApiResponse): + response = self.command(transaction = self.tx_hash) \ No newline at end of file diff --git a/test/commands/extended/traverse_bundle_test.py b/test/commands/extended/traverse_bundle_test.py new file mode 100644 index 00000000..e700c776 --- /dev/null +++ b/test/commands/extended/traverse_bundle_test.py @@ -0,0 +1,440 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from unittest import TestCase + +import filters as f +from filters.test import BaseFilterTestCase + +from iota import Address, BadApiResponse, Bundle, BundleHash, Fragment, Hash, \ + Iota, Tag, Transaction, TransactionHash, TransactionTrytes, Nonce +from iota.adapter import MockAdapter +from iota.commands.extended.traverse_bundle import TraverseBundleCommand +from iota.filters import Trytes + + +# Same tests as for GetBundlesRequestFilter (it is the same filter) +class TraverseBundleRequestFilterTestCase(BaseFilterTestCase): + filter_type = TraverseBundleCommand(MockAdapter()).get_request_filter + skip_value_check = True + + def setUp(self): + super(TraverseBundleRequestFilterTestCase, self).setUp() + + # noinspection SpellCheckingInspection + self.transaction = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999KPZOTR' + 'VDB9GZDJGZSSDCBIX9QOK9PAV9RMDBGDXLDTIZTWQ' + ) + + def test_pass_happy_path(self): + """ + Request is valid. + """ + # Raw trytes are extracted to match the IRI's JSON protocol. + request = { + 'transaction': self.transaction, + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, request) + + def test_pass_compatible_types(self): + """ + Request contains values that can be converted to the expected + types. + """ + filter_ = self._filter({ + # Any TrytesCompatible value will work here. + 'transaction': TransactionHash(self.transaction), + }) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + + { + 'transaction': self.transaction, + }, + ) + + def test_fail_empty(self): + """ + Request is empty. + """ + self.assertFilterErrors( + {}, + + { + 'transaction': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + def test_fail_unexpected_parameters(self): + """ + Request contains unexpected parameters. + """ + self.assertFilterErrors( + { + 'transaction': TransactionHash(self.transaction), + + # SAY "WHAT" AGAIN! + 'what': 'augh!', + }, + + { + 'what': [f.FilterMapper.CODE_EXTRA_KEY], + }, + ) + + def test_fail_transaction_wrong_type(self): + """ + ``transaction`` is not a TrytesCompatible value. + """ + self.assertFilterErrors( + { + 'transaction': 42, + }, + + { + 'transaction': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_transaction_not_trytes(self): + """ + ``transaction`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'transaction': b'not valid; must contain only uppercase and "9"', + }, + + { + 'transaction': [Trytes.CODE_NOT_TRYTES], + }, + ) + + +# noinspection SpellCheckingInspection +class TraverseBundleCommandTestCase(TestCase): + def setUp(self): + super(TraverseBundleCommandTestCase, self).setUp() + + self.adapter = MockAdapter() + self.command = TraverseBundleCommand(self.adapter) + + def test_wireup(self): + """ + Verifies that the command is wired up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).traverseBundle, + TraverseBundleCommand, + ) + + def test_single_transaction(self): + """ + Getting a bundle that contains a single transaction. + """ + transaction =\ + Transaction( + current_index = 0, + last_index = 0, + tag = Tag(b''), + timestamp = 1484960990, + value = 0, + attachment_timestamp = 1484960990, + attachment_timestamp_lower_bound = 12, + attachment_timestamp_upper_bound = 0, + + # These values are not relevant for 0-value transactions. + nonce = Nonce(b''), + signature_message_fragment = Fragment(b''), + + # This value is computed automatically, so it has to be real. + hash_ = + TransactionHash( + b'XPJIYZWPF9LBCYZPNBFARDRCSUGJGF9TWZT9K9PX' + b'VYDFPZOZBGXUCKLTJEUCFBEKQQ9VCSQVQDMMJQAY9', + ), + + address = + Address( + b'TESTVALUE9DONTUSEINPRODUCTION99999OCSGVF' + b'IBQA99KGTCPCZ9NHR9VGLGADDDIEGGPCGBDEDDTBC', + ), + + bundle_hash = + BundleHash( + b'TESTVALUE9DONTUSEINPRODUCTION99999DIOAZD' + b'M9AIUHXGVGBC9EMGI9SBVBAIXCBFJ9EELCPDRAD9U', + ), + + branch_transaction_hash = + TransactionHash( + b'TESTVALUE9DONTUSEINPRODUCTION99999BBCEDI' + b'ZHUDWBYDJEXHHAKDOCKEKDFIMB9AMCLFW9NBDEOFV', + ), + + trunk_transaction_hash = + TransactionHash( + b'TESTVALUE9DONTUSEINPRODUCTION999999ARAYA' + b'MHCB9DCFEIWEWDLBCDN9LCCBQBKGDDAECFIAAGDAS', + ), + ) + + self.adapter.seed_response('getTrytes', { + 'trytes': [transaction.as_tryte_string()], + }) + + response = self.command(transaction=transaction.hash) + + bundle = response['bundles'][0] # type: Bundle + self.assertEqual(len(bundle), 1) + + self.maxDiff = None + self.assertDictEqual( + bundle[0].as_json_compatible(), + transaction.as_json_compatible(), + ) + + def test_multiple_transactions(self): + """ + Getting a bundle that contains multiple transactions. + """ + bundle = Bundle.from_tryte_strings([ + TransactionTrytes( + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999WUQXEGBVIECGIWO9IGSYKWWPYCIVUJJGSJPWGIAFJPYSF9NSQOHWAHS9P' + b'9PWQHOBXNNQIF9IRHVQXKPZW999999999999999999999999999XZUIENOTTBKJMDP' + b'RXWGQYG9PWGTHNLFMVD99A99999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' + b'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXBGUEHHGAIWWQ' + b'BCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJLEDAMYVRGABAWBY9' + b'999MYIYBTGIOQYYZFJBLIAWMPSZEFFTXUZPCDIXSLLQDQSFYGQSQOGSPKCZNLVSZ9L' + b'MCUWVNGEN9EJEW9999XZUIENOTTBKJMDPRXWGQYG9PWGTXUO9AXMP9FLMDRMADLRPW' + b'CZCJBROYCDRJMYU9HDYJM9NDBFUPIZVTR' + ), + + # Well, it was bound to happen sooner or later... the ASCII + # representation of this tryte sequence contains a very naughty + # phrase. But I don't feel like doing another POW, so... enjoy. + TransactionTrytes( + b'NBTCPCFDEACCPCBDVC9DTCQAJ9RBTC9D9DCDQAEAKDCDFD9DSCFAJ9VBCDJDTCQAJ9' + b'ZBMDYBCCKB99999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999SYRABNN9JD9PNDL' + b'IKUNCECUELTOHNLFMVD99999999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' + b'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXFSEWUNJOEGNU' + b'I9QOCRFMYSIFAZLJHKZBPQZZYFG9ORYCRDX9TOMJPFCRB9R9KPUUGFPVOWYXFIWEW9' + b'999BGUEHHGAIWWQBCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJL' + b'EDAMYVRGABAWBY9999SYRABNN9JD9PNDLIKUNCECUELTOQZPSBDILVHJQVCEOICFAD' + b'YKZVGMOAXJRQNTCKMHGTAUMPGJJMX9LNF' + ), + ]) + + for txn in bundle: + self.adapter.seed_response('getTrytes', { + 'trytes': [txn.as_tryte_string()], + }) + + self.adapter.seed_response('getTrytes', { + 'trytes': [ + 'SPAMSPAMSPAM999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999999999999999999' + '999999999999999999999999999999999999999999999999999JECDITWO9999999' + '999999999999ONLFMVD99999999999999999999VVCHSQSRVFKSBONDWB9EAQEMQOY' + 'YRBIZHTBJLYNAVDHZPUZAZ9LYHXWKBEJ9IPR9FAMFLT9EEOHVYWUPRHHSRCILCLWFD' + 'GBYBFFOKMCSAPVD9VGZZRRGBLGMZMXD9RMZQDBLMGN9BATWZGULRBCYQEIKIRBPHC9' + '999KTLTRSYOWBD9HVNP9GCUABARNGMYXUZKXWRPGOPETZLKYYC9Z9EYXIWVARUBMBM' + 'BPXGORN9WPBLY99999ZRBVQWULRFXDNDYZKRKIXPZQT9JJJH9FZU9PVWZJWLXBPODP' + 'EHMKTTAGEPLPHUQCZNLDSHERONOMHJCOI' + ], + }) + + response = self.command( + transaction = + TransactionHash( + b'TOYJPHKMLQNDVLDHDILARUJCCIUMQBLUSWPCTIVA' + b'DRXICGYDGSVPXFTILFFGAPICYHGGJ9OHXINFX9999' + ), + ) + self.maxDiff = None + self.assertListEqual( + response['bundles'][0].as_json_compatible(), + bundle.as_json_compatible(), + ) + + def test_non_tail_transaction(self): + """ + Trying to get a bundle for a non-tail transaction. + + This is not valid; you have to start with a tail transaction. + """ + self.adapter.seed_response('getTrytes', { + 'trytes': [ + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999WUQXEGBVIECGIWO9IGSYKWWPYCIVUJJGSJPWGIAFJPYSF9NSQOHWAHS9P' + b'9PWQHOBXNNQIF9IRHVQXKPZW999999999999999999999999999999999999999999' + b'999999999999HNLFMVD99A99999999A99999999PDQWLVVDPUU9VIBODGMRIAZPGQX' + b'DOGSEXIHKIBWSLDAWUKZCZMK9Z9YZSPCKBDJSVDPRQLJSTKUMTNVSXBGUEHHGAIWWQ' + b'BCJZHZAQOWZMAIDAFUZBVMUVPWQJLUGGQKNKLMGTWXXNZKUCBJLEDAMYVRGABAWBY9' + b'999MYIYBTGIOQYYZFJBLIAWMPSZEFFTXUZPCDIXSLLQDQSFYGQSQOGSPKCZNLVSZ9L' + b'MCUWVNGEN9EJEW9999XZUIENOTTBKJMDPRXWGQYG9PWGTXUO9AXMP9FLMDRMADLRPW' + b'CZCJBROYCDRJMYU9HDYJM9NDBFUPIZVTR' + ], + }) + + with self.assertRaises(BadApiResponse): + self.command( + transaction = + TransactionHash( + b'FSEWUNJOEGNUI9QOCRFMYSIFAZLJHKZBPQZZYFG9' + b'ORYCRDX9TOMJPFCRB9R9KPUUGFPVOWYXFIWEW9999' + ), + ) + + def test_missing_transaction(self): + """ + Unable to find the requested transaction. + """ + self.adapter.seed_response('getTrytes', {'trytes': []}) + + with self.assertRaises(BadApiResponse): + self.command( + transaction = + TransactionHash( + b'FSEWUNJOEGNUI9QOCRFMYSIFAZLJHKZBPQZZYFG9' + b'ORYCRDX9TOMJPFCRB9R9KPUUGFPVOWYXFIWEW9999' + ), + ) From d9a61a67aaced25b27a57333c98dba6a0a5e3b24 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Tue, 5 Nov 2019 11:26:52 +0100 Subject: [PATCH 05/20] is_promotable: Test improvements and optimization --- iota/commands/extended/is_promotable.py | 9 +-- test/commands/extended/is_promotable_test.py | 59 ++++++++++---------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/iota/commands/extended/is_promotable.py b/iota/commands/extended/is_promotable.py index 4e3fa2d1..11f4bb83 100644 --- a/iota/commands/extended/is_promotable.py +++ b/iota/commands/extended/is_promotable.py @@ -81,8 +81,9 @@ def _execute(self, request): } # Check timestamps + now = get_current_ms() for tx in transactions: - is_within = is_within_depth(tx.attachment_timestamp) + is_within = is_within_depth(tx.attachment_timestamp, now) if not is_within: # Inform the user about what went wrong. response['info'].append('Transaction {tx_hash} is above max depth.'.format( @@ -93,7 +94,7 @@ def _execute(self, request): # If there are no problems, we don't need 'info' field if response['promotable']: - del response['info'] + response['info'] = None return response @@ -106,10 +107,10 @@ def __init__(self): f.FilterRepeater(f.Required | Trytes(TransactionHash)), }) -def is_within_depth(attachment_timestamp, depth=DEPTH): +def is_within_depth(attachment_timestamp, now, depth=DEPTH): + # type (int, int, Optiona(int)) -> bool """ Checks if `attachment_timestamp` is within limits of `depth`. """ - now = get_current_ms() return attachment_timestamp < now and \ now - attachment_timestamp < depth * MILESTONE_INTERVAL - ONE_WAY_DELAY \ No newline at end of file diff --git a/test/commands/extended/is_promotable_test.py b/test/commands/extended/is_promotable_test.py index 04961b38..6710a35f 100644 --- a/test/commands/extended/is_promotable_test.py +++ b/test/commands/extended/is_promotable_test.py @@ -11,7 +11,7 @@ Transaction from iota.adapter import MockAdapter from iota.commands.extended.is_promotable import IsPromotableCommand, \ - get_current_ms, is_within_depth + get_current_ms, is_within_depth, MILESTONE_INTERVAL, ONE_WAY_DELAY from iota.filters import Trytes from test import mock @@ -281,6 +281,12 @@ def setUp(self): 'ZOIMUWZALX9IVZ9999' ) + # Tuesday, October 29, 2019 4:19:43.600 PM GMT+01:00 + self.valid_now = 1572362383600 + """ + Timestamp that is just greater than the later timestamp in self.trytes. + """ + def test_wireup(self): """ Verify that the command is wired up correctly. @@ -294,18 +300,6 @@ def test_happy_path(self): """ Successfully checking promotability. """ - # To pass timestamp check, we need a timestamp that defines - # a moment no earlier than 660s from the moment the function - # is called during test. To do this, we cheat a bit: - tx = Transaction.from_tryte_string(self.trytes1) - tx.attachment_timestamp = get_current_ms() - # self.trytes1 becomes invalid transaction since we modified - # attachment_timestamp, but it doesn't matter here - self.trytes1 = tx.as_tryte_string() - # The same for self.trytes2 - tx = Transaction.from_tryte_string(self.trytes2) - tx.attachment_timestamp = get_current_ms() - self.trytes2 = tx.as_tryte_string() self.adapter.seed_response('checkConsistency', { 'state': True, @@ -314,15 +308,18 @@ def test_happy_path(self): 'trytes': [self.trytes1, self.trytes2] }) - response = self.command(tails=[self.hash1, self.hash2]) + with mock.patch('iota.commands.extended.is_promotable.get_current_ms', + mock.MagicMock(return_value=self.valid_now)): + response = self.command(tails=[self.hash1, self.hash2]) - self.assertDictEqual( - response, + self.assertDictEqual( + response, - { - 'promotable': True, - } - ) + { + 'promotable': True, + 'info': None, + } + ) def test_not_consistent(self): """ @@ -365,6 +362,9 @@ def test_one_timestamp_invalid(self): 'trytes': [self.trytes1, self.trytes2] }) + # Here we don`t mock get_current_ms. + # Tx 1 will have updated, passing timestamp. + # Tx 2 has the old one, so should fail. response = self.command(tails=[self.hash1, self.hash2]) self.assertDictEqual( @@ -382,26 +382,27 @@ def test_is_within_depth(self): """ Test ``is_within_depth`` helper method. """ - # Timestamp is too old (depth=3) - old_timestamp = get_current_ms() - 660000 + # Timestamp is too old (depth=6) + now = get_current_ms() + old_timestamp = now - (6 * MILESTONE_INTERVAL - ONE_WAY_DELAY) self.assertEqual( - is_within_depth(old_timestamp), + is_within_depth(old_timestamp, now), False ) - # Timestamp points to the future - future_timestamp = get_current_ms() + 500000 + # Timestamp points to the future (any number would do) + future_timestamp = now + 10 self.assertEqual( - is_within_depth(future_timestamp), + is_within_depth(future_timestamp, now), False ) - # Timestamp is valid (one second 'old') - timestamp = get_current_ms() - 1000 + # Timestamp is valid ( appr. one second 'old') + timestamp = now - 1000 self.assertEqual( - is_within_depth(timestamp), + is_within_depth(timestamp, now), True ) \ No newline at end of file From dba8acf93ea0ff565b99fd10e817a98742279508 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Tue, 12 Nov 2019 16:04:47 +0100 Subject: [PATCH 06/20] Improve API documentation - Restructured API documentation into 3 parts: - API classes - Core API commands - Extended API commands - Improve docstrings of API calls and types. - Introduce autodoc support for shpinx to generate documentation from docstrings. - Add autosectionlabel extension to sphinx. --- CONTRIBUTING.rst | 2 +- README.rst | 6 +- docs/api.rst | 536 ++++------------------------------- docs/conf.py | 2 +- docs/core_api.rst | 87 ++++++ docs/extended_api.rst | 80 ++++++ docs/getting_started.rst | 8 +- docs/index.rst | 2 + iota/api.py | 580 ++++++++++++++++++++++++++++++-------- iota/transaction/types.py | 9 + 10 files changed, 699 insertions(+), 613 deletions(-) create mode 100644 docs/core_api.rst create mode 100644 docs/extended_api.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index b1327dc0..96428663 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -29,7 +29,7 @@ We're pretty open about how people contribute to PyOTA, but there are a few thin - Please do not post support requests here. Use the ``#python`` channel on `Discord`_ - Please do not propose new API methods here. There are multiple IOTA API libraries out there, and they must all have the same functionality. - - That said, if you have an idea for a new API method, please share it on the ``#developers`` channel in `Discord`_ so that IOTA Foundation members can evaluate it! + - That said, if you have an idea for a new API method, please share it on the ``#clients-discussion`` channel in `Discord`_ so that IOTA Foundation members can evaluate it! Need Some Inspiration? diff --git a/README.rst b/README.rst index e143fd21..a3a89889 100644 --- a/README.rst +++ b/README.rst @@ -26,9 +26,9 @@ Dependencies ============ PyOTA is compatible with Python 3.7, 3.6, 3.5 and 2.7 -============ -Installation -============ +============= +Install PyOTA +============= To install the latest version:: pip install pyota diff --git a/docs/api.rst b/docs/api.rst index 2d14977f..e74743e6 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,485 +1,59 @@ -Core API -======== - -The Core API includes all of the core API calls that are made -available by the current `IOTA Reference -Implementation `__. - -These methods are "low level" and generally do not need to be called -directly. - -For the full documentation of all the Core API calls, please refer -to the `official documentation `__. - -Extended API -============ - -The Extended API includes a number of "high level" commands to perform -tasks such as sending and receiving transfers. - -``broadcast_and_store`` ------------------------ - -Broadcasts and stores a set of transaction trytes. - -Parameters -~~~~~~~~~~ - -- ``trytes: Iterable[TransactionTrytes]``: Transaction trytes. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``trytes: List[TransactionTrytes]``: Transaction trytes that were - broadcast/stored. Should be the same as the value of the ``trytes`` - parameter. - -``broadcast_bundle`` ------------------------ - -Re-broadcasts all transactions in a bundle given the tail transaction hash. -It might be useful when transactions did not properly propagate, -particularly in the case of large bundles. - -Parameters -~~~~~~~~~~ - -- ``tail_hash: TransactionHash``: Transaction hash of the tail transaction - of the bundle. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``trytes: List[TransactionTrytes]``: Transaction trytes that were - broadcast. - -``find_transaction_objects`` ----------------------------- - -A more extensive version of the core API ``find_transactions`` that returns -transaction objects instead of hashes. - -Effectively, this is ``find_transactions`` + ``get_trytes`` + converting -the trytes into transaction objects. It accepts the same parameters -as ``find_transactions`` - -Find the transactions which match the specified input. -All input values are lists, for which a list of return values -(transaction hashes), in the same order, is returned for all -individual elements. Using multiple of these input fields returns the -intersection of the values. - -Parameters -~~~~~~~~~~ - -- ``bundles: Optional[Iterable[BundleHash]]``: List of bundle IDs. -- ``addresses: Optional[Iterable[Address]]``: List of addresses. -- ``tags: Optional[Iterable[Tag]]``: List of tags. -- ``param: Optional[Iterable[TransactionHash]]``: List of approvee - transaction IDs. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``transactions: List[Transaction]``: List of Transaction objects that - match the input - -``get_account_data`` --------------------- - -More comprehensive version of ``get_transfers`` that returns addresses -and account balance in addition to bundles. - -This function is useful in getting all the relevant information of your -account. - -Parameters -~~~~~~~~~~ - -- ``start: int``: Starting key index. - -- ``stop: Optional[int]``: Stop before this index. Note that this - parameter behaves like the ``stop`` attribute in a ``slice`` object; - the stop index is *not* included in the result. - -- If ``None`` (default), then this method will check every address - until it finds one without any transfers. - -- ``inclusion_states: bool`` Whether to also fetch the inclusion states - of the transfers. This requires an additional API call to the node, - so it is disabled by default. - -Return -~~~~~~ - -This method returns a dict with the following items: - -- ``addresses: List[Address]``: List of generated addresses. Note that - this list may include unused addresses. - -- ``balance: int``: Total account balance. Might be 0. - -- ``bundles: List[Bundles]``: List of bundles with transactions to/from - this account. - -``get_bundles`` ---------------- - -Given a ``TransactionHash``, returns the bundle(s) associated with it. - -Parameters -~~~~~~~~~~ - -- ``transaction: TransactionHash``: Hash of a tail transaction. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``bundles: List[Bundle]``: List of matching bundles. Note that this - value is always a list, even if only one bundle was found. - -``get_inputs`` +PyOTA API Classes +================= + +**PyOTA offers you the Python API to interact with the IOTA network. +The available methods can be grouped into two categories:** + ++------------------------------------+------------------------------------+ +| Core API | Extended API | ++====================================+====================================+ +| | | +| | | Builds on top of the Core API to | +| | API commands for direct | | perform more complex operations, | +| | interaction with a node. | | and abstract away low-level IOTA | +| | | specific procedures. | +| | | ++------------------------------------+------------------------------------+ + +To use the API in your Python application or script, declare an +API instance of any of the two above. +**Since the Extended API incorporates the Core API, usually you end up only +using the Extended API,** but if for some reason you need only the core +functionality, the library is there to help you. + +.. code-block:: + :linenos: + :emphasize-lines: 3,4 + + from iota import Iota, StrictIota + + # This is how you declare an Extended API, use the methods of this object. + api = Iota('adapter-specification') + + # This is how you declare a Core API, use the methods of this object. + api = StrictIota('adapter-specification') + +.. py:module:: iota + +The PyOTA speific :py:class:`StrictIota` class implements the Core API, +while :py:class:`Iota` implements the Extended API. From a Python +implementation point of view, :py:class:`Iota` is a subclass of +:py:class:`StrictIota`, therefore it inherits every method and attribute +the latter has. + +Take a look on the class definitions and notice that :py:class:`Iota` +has a :py:class:`Seed` attribute. This is becasue the Extended API is able +to generate private keys, addresses and signatures from your seed. +**Your seed never leaves the library and your machine!** + +Core API Class -------------- -Gets all possible inputs of a seed and returns them with the total -balance. - -This is either done deterministically (by generating all addresses until -``find_transactions`` returns an empty result), or by providing a key -range to search. - -Parameters -~~~~~~~~~~ - -- ``start: int``: Starting key index. Defaults to 0. -- ``stop: Optional[int]``: Stop before this index. -- Note that this parameter behaves like the ``stop`` attribute in a - ``slice`` object; the stop index is *not* included in the result. -- If ``None`` (default), then this method will not stop until it finds - an unused address. -- ``threshold: Optional[int]``: If set, determines the minimum - threshold for a successful result: -- As soon as this threshold is reached, iteration will stop. -- If the command runs out of addresses before the threshold is reached, - an exception is raised. -- If ``threshold`` is 0, the first address in the key range with a - non-zero balance will be returned (if it exists). -- If ``threshold`` is ``None`` (default), this method will return - **all** inputs in the specified key range. - -Note that this method does not attempt to "optimize" the result (e.g., -smallest number of inputs, get as close to ``threshold`` as possible, -etc.); it simply accumulates inputs in order until the threshold is met. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``inputs: List[Address]``: Addresses with nonzero balances that can - be used as inputs. -- ``totalBalance: int``: Aggregate balance of all inputs found. - -``get_latest_inclusion`` ------------------------- - -Fetches the inclusion state for the specified transaction hashes, as of -the latest milestone that the node has processed. - -Parameters -~~~~~~~~~~ - -- ``hashes: Iterable[TransactionHash]``: Iterable of transaction - hashes. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``: bool``: Inclusion state for a single - transaction. - -There will be one item per transaction hash in the ``hashes`` parameter. - -``get_new_addresses`` ---------------------- - -Generates one or more new addresses from the seed. - -Parameters -~~~~~~~~~~ - -- ``index: int``: Specify the index of the new address (must be >= 1). -- ``count: Optional[int]``: Number of addresses to generate (must be >= - 1). -- If ``None``, this method will scan the Tangle to find the next - available unused address and return that. -- ``security_level: int``: Number of iterations to use when generating - new addresses. Lower values generate addresses faster, higher values - result in more secure signatures in transactions. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``addresses: List[Address]``: The generated address(es). Note that - this value is always a list, even if only one address was generated. - -``get_transaction_objects`` ---------------------------- -Returns a list of transaction objects given a list of transaction hashes. -This is effectively calling ``get_trytes`` and converting the trytes to -transaction objects. -Similar to ``find_transaction_objects``, but input is list of hashes. - -Parameters -~~~~~~~~~~ - -- ``hashes``: List of transaction hashes that should be fetched. - -Return -~~~~~~ - -Returns a ``dict`` with the following items: - -- ``transactions: List[Transaction]``: List of transaction objects. - -``get_transfers`` ------------------ - -Returns all transfers associated with the seed. - -Parameters -~~~~~~~~~~ - -- ``start: int``: Starting key index. -- ``stop: Optional[int]``: Stop before this index. -- Note that this parameter behaves like the ``stop`` attribute in a - ``slice`` object; the stop index is *not* included in the result. -- If ``None`` (default), then this method will check every address - until it finds one without any transfers. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``bundles: List[Bundle]``: Matching bundles, sorted by tail - transaction timestamp. - -``is_promotable`` -------------------- - -This extended API function helps you to determine whether a tail transaction -(bundle) is promotable. -Example usage could be to determine if a transaction can be promoted or you -should reattach (``replay_bundle``). - -The method takes a list of tail transaction hashes, calls ``check_consistency`` -to verify consistency. If successful, fetches the transaction trytes from the -Tangle and checks if ``attachment_timestamp`` is within reasonable limits. - -Parameters -~~~~~~~~~~ - -- ``tails: List[TransactionHash]``: Tail transaction hashes to check. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``promotable: bool``: ``True``, if: - - - Tails are consistent. - See `API Reference `_. - - ``attachment_timestamp`` for all transactions are less than current time - and attachement happened no earlier than ``depth`` milestones. - By default, ``depth`` = 6. - - parameter is ``False`` otherwise. - -- ``info: Optional(List[String])``: If ``promotable`` is ``False``, contains information - about the error. - -``is_reattachable`` -------------------- - -This API function helps you to determine whether you should replay a -transaction or make a new one (either with the same input, or a -different one). - -This method takes one or more input addresses (i.e. from spent -transactions) as input and then checks whether any transactions with a -value transferred are confirmed. - -If yes, it means that this input address has already been successfully -used in a different transaction, and as such you should no longer replay -the transaction. - -Parameters -~~~~~~~~~~ - -- ``address: Iterable[Address]``: List of addresses. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``reattachable: List[Bool]``: Always a list, even if only one address - was queried. - -``prepare_transfer`` --------------------- - -Prepares transactions to be broadcast to the Tangle, by generating the -correct bundle, as well as choosing and signing the inputs (for value -transfers). - -Parameters -~~~~~~~~~~ - -- ``transfers: Iterable[ProposedTransaction]``: Transaction objects to - prepare. -- ``inputs: Optional[Iterable[Address]]``: List of addresses used to - fund the transfer. Ignored for zero-value transfers. -- If not provided, addresses will be selected automatically by scanning - the Tangle for unspent inputs. -- ``change_address: Optional[Address]``: If inputs are provided, any - unspent amount will be sent to this address. -- If not specified, a change address will be generated automatically. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``trytes: List[TransactionTrytes]``: Raw trytes for the transactions - in the bundle, ready to be provided to ``send_trytes``. - -``promote_transaction`` ------------------------ - -Promotes a transaction by adding spam on top of it. - -- ``transaction: TransactionHash``: Transaction hash. Must be a tail. -- ``depth: int``: Depth at which to attach the bundle. -- ``min_weight_magnitude: Optional[int]``: Min weight magnitude, used - by the node to calibrate Proof of Work. -- If not provided, a default value will be used. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``bundle: Bundle``: The newly-published bundle. - -``replay_bundle`` ------------------ - -Takes a tail transaction hash as input, gets the bundle associated with -the transaction and then replays the bundle by attaching it to the -Tangle. - -Parameters -~~~~~~~~~~ - -- ``transaction: TransactionHash``: Transaction hash. Must be a tail. -- ``depth: int``: Depth at which to attach the bundle. -- ``min_weight_magnitude: Optional[int]``: Min weight magnitude, used - by the node to calibrate Proof of Work. -- If not provided, a default value will be used. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``trytes: List[TransactionTrytes]``: Raw trytes that were published - to the Tangle. - -``send_transfer`` ------------------ - -Prepares a set of transfers and creates the bundle, then attaches the -bundle to the Tangle, and broadcasts and stores the transactions. - -Parameters -~~~~~~~~~~ - -- ``depth: int``: Depth at which to attach the bundle. -- ``transfers: Iterable[ProposedTransaction]``: Transaction objects to - prepare. -- ``inputs: Optional[Iterable[Address]]``: List of addresses used to - fund the transfer. Ignored for zero-value transfers. -- If not provided, addresses will be selected automatically by scanning - the Tangle for unspent inputs. -- ``change_address: Optional[Address]``: If inputs are provided, any - unspent amount will be sent to this address. -- If not specified, a change address will be generated automatically. -- ``min_weight_magnitude: Optional[int]``: Min weight magnitude, used - by the node to calibrate Proof of Work. -- If not provided, a default value will be used. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``bundle: Bundle``: The newly-published bundle. - -``send_trytes`` ---------------- - -Attaches transaction trytes to the Tangle, then broadcasts and stores -them. - -Parameters -~~~~~~~~~~ - -- ``trytes: Iterable[TransactionTrytes]``: Transaction trytes to - publish. -- ``depth: int``: Depth at which to attach the bundle. -- ``min_weight_magnitude: Optional[int]``: Min weight magnitude, used - by the node to calibrate Proof of Work. -- If not provided, a default value will be used. - -Return -~~~~~~ - -This method returns a ``dict`` with the following items: - -- ``trytes: List[TransactionTrytes]``: Raw trytes that were published - to the Tangle. - -``traverse_bundle`` -------------------- - -Given a tail ``TransactionHash``, returns the bundle(s) associated with it. -Unlike ``get_bundles``, this command does not validate the fetched bundle(s). - -Parameters -~~~~~~~~~~ - -- ``tail_hash: TransactionHash``: Hash of a tail transaction. - -Return -~~~~~~ +.. autoclass:: StrictIota + :members: set_local_pow -This method returns a ``dict`` with the following items: +Extended API Class +------------------ -- ``bundles: List[Bundle]``: List of matching bundles. Note that this - value is always a list, even if only one bundle was found. +.. autoclass:: Iota + :members: set_local_pow diff --git a/docs/conf.py b/docs/conf.py index b524b3cb..9a66bd0d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.autosectionlabel'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/core_api.rst b/docs/core_api.rst new file mode 100644 index 00000000..37d4d5d0 --- /dev/null +++ b/docs/core_api.rst @@ -0,0 +1,87 @@ +Core API Methods +================ + +The Core API includes all of the core API calls that are made +available by the current `IOTA Reference +Implementation `__. + +These methods are "low level" and generally do not need to be called +directly. + +For the full documentation of all the Core API calls, please refer +to the `official documentation `__. + +.. py:currentmodule:: iota + +``add_neighbors`` +----------------- +.. automethod:: Iota.add_neighbors + +``attach_to_tangle`` +-------------------- +.. automethod:: Iota.attach_to_tangle + +``broadcast_transactions`` +-------------------------- +.. automethod:: Iota.broadcast_transactions + +``check_consistency`` +--------------------- +.. automethod:: Iota.check_consistency + +``find_transactions`` +--------------------- +.. automethod:: Iota.find_transactions + +``get_balances`` +---------------- +.. automethod:: Iota.get_balances + +``get_inclusion_states`` +------------------------ +.. automethod:: Iota.get_inclusion_states + +``get_missing_transactions`` +---------------------------- +.. automethod:: Iota.get_missing_transactions + +``get_neighbors`` +----------------- +.. automethod:: Iota.get_neighbors + +``get_node_api_configuration`` +------------------------------ +.. automethod:: Iota.get_node_api_configuration + +``get_node_info`` +----------------- +.. automethod:: Iota.get_node_info + +``get_tips`` +------------ +.. automethod:: Iota.get_tips + +``get_transactions_to_approve`` +------------------------------- +.. automethod:: Iota.get_transactions_to_approve + +``get_trytes`` +-------------- +.. automethod:: Iota.get_trytes + +``interrupt_attaching_to_tangle`` +--------------------------------- +.. automethod:: Iota.interrupt_attaching_to_tangle + +``remove_neighbors`` +-------------------- +.. automethod:: Iota.remove_neighbors + +``store_transactions`` +---------------------- +.. automethod:: Iota.store_transactions + +``were_addresses_spent_from`` +----------------------------- +.. automethod:: Iota.were_addresses_spent_from \ No newline at end of file diff --git a/docs/extended_api.rst b/docs/extended_api.rst new file mode 100644 index 00000000..59b23b85 --- /dev/null +++ b/docs/extended_api.rst @@ -0,0 +1,80 @@ +Extended API Methods +==================== + +The Extended API includes a number of "high level" commands to perform +tasks such as sending and receiving transfers. + +.. py:currentmodule:: iota + +``broadcast_and_store`` +----------------------- +.. automethod:: Iota.broadcast_and_store + +``broadcast_bundle`` +-------------------- +.. automethod:: Iota.broadcast_bundle + +``find_transaction_objects`` +---------------------------- +.. automethod:: Iota.find_transaction_objects + +``get_account_data`` +-------------------- +.. automethod:: Iota.get_account_data + +``get_bundles`` +--------------- +.. automethod:: Iota.get_bundles + +``get_inputs`` +-------------- +.. automethod:: Iota.get_inputs + +``get_latest_inclusion`` +------------------------ +.. automethod:: Iota.get_latest_inclusion + +``get_new_addresses`` +--------------------- +.. automethod:: Iota.get_new_addresses + +``get_transaction_objects`` +--------------------------- +.. automethod:: Iota.get_transaction_objects + +``get_transfers`` +----------------- +.. automethod:: Iota.get_transfers + +``is_promotable`` +----------------- +.. automethod:: Iota.is_promotable + +``is_reattachable`` +------------------- +.. automethod:: Iota.is_reattachable + +``prepare_transfer`` +-------------------- +.. automethod:: Iota.prepare_transfer + +``promote_transaction`` +----------------------- +.. automethod:: Iota.promote_transaction + +``replay_bundle`` +----------------- +.. automethod:: Iota.replay_bundle + +``send_transfer`` +----------------- +.. automethod:: Iota.send_transfer + +``send_trytes`` +--------------- +.. automethod:: Iota.send_trytes + +``traverse_bundle`` +------------------- +.. automethod:: Iota.traverse_bundle + diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 0602b179..7eb8d1a5 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -15,6 +15,10 @@ Install PyOTA using `pip`: This extension boosts the performance of certain crypto operations significantly (speedups of 60x are common). +.. py:currentmodule:: iota.Iota + +.. _pow-label: + .. note:: The ``[pow]`` extra installs the optional `PyOTA-PoW extension`_. @@ -27,8 +31,8 @@ Install PyOTA using `pip`: api = Iota('https://nodes.thetangle.org:443', local_pow=True) - Or the ``set_local_pow`` method of the api class to dynamically enable/disable - the local proof-of-work feature. + Or the :py:meth:`set_local_pow` method of the api class to dynamically + enable/disable the local proof-of-work feature. Getting Started =============== diff --git a/docs/index.rst b/docs/index.rst index ba7b24bd..9f1dab8d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,8 @@ adapters addresses api + core_api + extended_api multisig .. include:: ../README.rst diff --git a/iota/api.py b/iota/api.py index 956bef58..47dcb7b8 100644 --- a/iota/api.py +++ b/iota/api.py @@ -64,17 +64,45 @@ class StrictIota(object): References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference + + :param AdapterSpec adapter: + URI string or BaseAdapter instance. + + :param Optional[bool] testnet: + Whether to use testnet settings for this instance. + On the testnet, minimum weight magnitude is set to 9, on mainnet + it is 1 by default. + + :param Optional[bool] local_pow: + Whether to perform proof-of-work locally by redirecting all calls + to :py:meth:`attach_to_tangle` to + `ccurl pow interface `_. + + See :ref:`Optional Local Pow` for more info and + :ref:`find out` how to use it. + """ commands = discover_commands('iota.commands.core') def __init__(self, adapter, testnet=False, local_pow=False): # type: (AdapterSpec, bool, bool) -> None """ - :param adapter: + :param AdapterSpec adapter: URI string or BaseAdapter instance. - :param testnet: + :param bool testnet: Whether to use testnet settings for this instance. + On the testnet, minimum weight magnitude is set to 9, on mainnet + it is 1 by default. + + :param Optional[bool] local_pow: + Whether to perform proof-of-work locally by redirecting all calls + to :py:meth:`attach_to_tangle` to + `ccurl pow interface `_. + + See :ref:`Optional Local Pow` for more info and + :ref:`find out` how to use it. + """ super(StrictIota, self).__init__() @@ -106,7 +134,7 @@ def __getattr__(self, command): If you want to execute an arbitrary API command, use :py:meth:`create_command`. - :param command: + :param Text command: The name of the command to create. References: @@ -145,20 +173,29 @@ def create_command(self, command): This method is useful for invoking undocumented or experimental methods, or if you just want to troll your node for awhile. - :param command: + :param Text command: The name of the command to create. + """ return CustomCommand(self.adapter, command) def set_local_pow(self, local_pow): # type: (bool) -> None """ - Sets the local_pow attribute of the adapter of the api instance. - If it is true, attach_to_tangle command calls external interface - to perform pow, instead of sending the request to a node. - By default, it is set to false. + Sets the :py:attr:`local_pow` attribute of the adapter of the api + instance. If it is ``True``, :py:meth:`attach_to_tangle` command calls + external interface to perform proof of work, instead of sending the + request to a node. + + By default, :py:attr:`local_pow` is set to ``False``. This particular method is needed if one wants to change local_pow behavior dynamically. + + :param bool local_pow: + Whether to perform pow locally. + + :returns: None + """ self.adapter.set_local_pow(local_pow) @@ -177,7 +214,7 @@ def add_neighbors(self, uris): Add one or more neighbors to the node. Lasts until the node is restarted. - :param uris: + :param Iterable[Text] uris: Use format ``://:``. Example: ``add_neighbors(['udp://example.com:14265'])`` @@ -186,6 +223,16 @@ def add_neighbors(self, uris): weird things will happen if you specify a node's HTTP API URI here). + :return: + ``dict`` with the following structure:: + + { + 'addedNeighbors': int, + Total number of added neighbors. + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#addneighbors @@ -205,12 +252,33 @@ def attach_to_tangle( doing Proof of Work. You need to supply branchTransaction as well as trunkTransaction (basically the tips which you're going to validate and reference with this transaction) - both of which - you'll get through the getTransactionsToApprove API call. + you'll get through the :py:meth:`get_transactions_to_approve` API call. The returned value is a different set of tryte values which you can input into :py:meth:`broadcast_transactions` and :py:meth:`store_transactions`. + :param TransactionHash trunk_transaction: + Trunk transaction hash. + + :param TransactionHash branch_transaction: + Branch transaction hash. + + :param Iterable[TransactionTrytes] trytes: + List of transaction trytes in the bundle to be attached. + + :param Optional[int] min_weight_magnitude: + Minimum weight magnitude to be used for attaching trytes. + 14 by default on mainnet, 9 on testnet/devnet. + + :return: + ``dict`` with the following structure:: + + { + 'trytes': List[TransactionTrytes], + Transaction trytes that include a valid nonce field. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#attachtotangle @@ -233,6 +301,17 @@ def broadcast_transactions(self, trytes): The input trytes for this call are provided by :py:meth:`attach_to_tangle`. + :param Iterable[TransactionTrytes] trytes: + List of transaction trytes to be broadcast. + + :return: + ``dict`` with the following structure:: + + { + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#broadcasttransactions @@ -243,7 +322,7 @@ def check_consistency(self, tails): # type: (Iterable[TransactionHash]) -> dict """ Used to ensure tail resolves to a consistent ledger which is - necessary to validate before attempting promotionChecks + necessary to validate before attempting promotion. Checks transaction hashes for promotability. This is called with a pending transaction (or more of them) and @@ -252,18 +331,17 @@ def check_consistency(self, tails): one) to be confirmed, or not (because it conflicts with another already confirmed transaction). - :param tails: + :param Iterable[TransactionHash] tails: Transaction hashes. Must be tail transactions. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'state': bool, - - 'info': str, - This field will only exist set if ``state`` is - ``False``. + Whether tails resolve to consistent ledger. + 'info': Text, + This field will only exist if 'state' is ``False``. } References: @@ -293,18 +371,26 @@ def find_transactions( Using multiple of these input fields returns the intersection of the values. - :param bundles: + :param Optional[Iterable[BundleHash] bundles: List of bundle IDs. - :param addresses: + :param Optional[Iterable[Address]] addresses: List of addresses. - :param tags: + :param Optional[Iterable[Tag]] tags: List of tags. - :param approvees: + :param Optional[Iterable[TransactionHash]] approvees: List of approvee transaction IDs. + :return: + ``dict`` with the following structure:: + + { + 'hashes': List[TransationHash], + Found transactions. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#findtransactions @@ -333,15 +419,33 @@ def get_balances( determined. The balances are returned as a list in the same order as the addresses were provided as input. - :param addresses: + :param Iterable[Address] addresses: List of addresses to get the confirmed balance for. - :param threshold: + :param int threshold: Confirmation threshold between 0 and 100. - :param tips: + :param Optional[Iterable[TransactionHash]] tips: Tips whose history of transactions to traverse to find the balance. + :return: + ``dict`` with the following structure:: + + { + 'balances': List[int], + List of balances in the same order as the addresses + parameters that were passed to the endpoint. + 'references': List[TransactionHash], + The referencing tips. If no tips parameter was passed + to the endpoint, this field contains the hash of the + latest milestone that confirmed the balance. + 'milestoneIndex': int, + The index of the milestone that confirmed the most + recent balance. + 'duration': int, + Number of milliseconds it took to process the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#getbalances @@ -360,14 +464,26 @@ def get_inclusion_states(self, transactions, tips): network or not. You can search for multiple tips (and thus, milestones) to get past inclusion states of transactions. - :param transactions: + :param Iterable[TransactionHash] transactions: List of transactions you want to get the inclusion state for. - :param tips: + :param Iterable[TransactionHash] tips: List of tips (including milestones) you want to search for the inclusion state. + :return: + ``dict`` with the following structure:: + + { + 'states': List[bool], + List of boolean values in the same order as the + transactions parameters. A ``True`` value means the + transaction was confirmed. + 'duration': int, + Number of milliseconds it took to process the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#getinclusionstates @@ -383,6 +499,16 @@ def get_missing_transactions(self): Returns all transaction hashes that a node is currently requesting from its neighbors. + :return: + ``dict`` with the following structure:: + + { + 'hashes': List[TransactionHash], + Array of missing transaction hashes. + 'duration': int, + Number of milliseconds it took to process the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#getmissingtransactions @@ -397,6 +523,25 @@ def get_neighbors(self): The activity counter is reset after restarting IRI. + :return: + ``dict`` with the following structure:: + + { + 'neighbors': List[dict], + Array of objects, including the following fields with + example values: + "address": "/8.8.8.8:14265", + "numberOfAllTransactions": 158, + "numberOfRandomTransactionRequests": 271, + "numberOfNewTransactions": 956, + "numberOfInvalidTransactions": 539, + "numberOfStaleTransactions": 663, + "numberOfSentTransactions": 672, + "connectiontype": "TCP" + 'duration': int, + Number of milliseconds it took to process the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#getneighbors @@ -408,8 +553,21 @@ def get_node_api_configuration(self): """ Returns a node's API configuration settings. + :return: + ``dict`` with the following structure:: + + { + '': type, + Configuration parameters for a node. + ... + ... + ... + + } + References: + - https://docs.iota.org/docs/node-software/0.1/iri/references/iri-configuration-options - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#getnodeapiconfiguration """ return core.GetNodeAPIConfigurationCommand(self.adapter)() @@ -419,6 +577,54 @@ def get_node_info(self): """ Returns information about the node. + :return: + ``dict`` with the following structure:: + + { + 'appName': Text, + Name of the IRI network. + 'appVersion': Text, + Version of the IRI. + 'jreAvailableProcessors': int, + Available CPU cores on the node. + 'jreFreeMemory': int, + Amount of free memory in the Java virtual machine. + 'jreMaxMemory': int, + Maximum amount of memory that the Java virtual machine + can use, + 'jreTotalMemory': int, + Total amount of memory in the Java virtual machine. + 'jreVersion': Text, + The version of the Java runtime environment. + 'latestMilestone': TransactionHash + Transaction hash of the latest milestone. + 'latestMilestoneIndex': int, + Index of the latest milestone. + 'latestSolidSubtangleMilestone': TransactionHash, + Transaction hash of the latest solid milestone. + 'latestSolidSubtangleMilestoneIndex': int, + Index of the latest solid milestone. + 'milestoneStartIndex': int, + Start milestone for the current version of the IRI. + 'neighbors': int, + Total number of connected neighbor nodes. + 'packetsQueueSize': int, + Size of the packet queue. + 'time': int, + Current UNIX timestamp. + 'tips': int, + Number of tips in the network. + 'transactionsToRequest': int, + Total number of transactions that the node is missing in + its ledger. + 'features': List[Text], + Enabled configuration options. + 'coordinatorAddress': Address, + Address (Merkle root) of the Coordinator. + 'duration': int, + Number of milliseconds it took to process the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#getnodeinfo @@ -431,6 +637,16 @@ def get_tips(self): Returns the list of tips (transactions which have no other transactions referencing them). + :return: + ``dict`` with the following structure:: + + { + 'hashes': List[TransactionHash], + List of tip transaction hashes. + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#gettips @@ -444,19 +660,30 @@ def get_transactions_to_approve(self, depth, reference=None): Tip selection which returns ``trunkTransaction`` and ``branchTransaction``. - :param depth: - Determines how many bundles to go back to when finding the - transactions to approve. + :param int depth: + Number of milestones to go back to start the tip selection algorithm. The higher the depth value, the more "babysitting" the node will perform for the network (as it will confirm more transactions that way). - :param reference: + :param TransactionHash reference: Transaction hash from which to start the weighted random walk. Use this parameter to make sure the returned tip transaction hashes approve a given reference transaction. + :return: + ``dict`` with the following structure:: + + { + 'trunkTransaction': TransactionHash, + Valid trunk transaction hash. + 'branchTransaction': TransactionHash, + Valid branch transaction hash. + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#gettransactionstoapprove @@ -472,6 +699,22 @@ def get_trytes(self, hashes): Returns the raw transaction data (trytes) of one or more transactions. + :return: + ``dict`` with the following structure:: + + { + 'trytes': List[TransactionTrytes], + List of transaction trytes for the given transaction + hashes (in the same order as the parameters). + 'duration': int, + Number of milliseconds it took to complete the request. + } + + .. note:: + If a node doesn't have the trytes for a given transaction hash in + its ledger, the value at the index of that transaction hash is either + ``null`` or a string of 9s. + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#gettrytes @@ -484,6 +727,14 @@ def interrupt_attaching_to_tangle(self): Interrupts and completely aborts the :py:meth:`attach_to_tangle` process. + :return: + ``dict`` with the following structure:: + + { + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#interruptattachingtotangle @@ -496,10 +747,20 @@ def remove_neighbors(self, uris): Removes one or more neighbors from the node. Lasts until the node is restarted. - :param uris: + :param Text uris: Use format ``://:``. Example: `remove_neighbors(['udp://example.com:14265'])` + :return: + ``dict`` with the following structure:: + + { + 'removedNeighbors': int, + Total number of removed neighbors. + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#removeneighbors @@ -509,11 +770,24 @@ def remove_neighbors(self, uris): def store_transactions(self, trytes): # type: (Iterable[TryteString]) -> dict """ - Store transactions into local storage. + Store transactions into local storage of the node. The input trytes for this call are provided by :py:meth:`attach_to_tangle`. + :param TransactionTrytes trytes: + Valid transaction trytes returned by :py:meth:`attach_to_tangle`. + + :return: + ``dict`` with the following structure:: + + { + 'trytes': TransactionTrytes, + Stored trytes. + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#storetransactions @@ -526,9 +800,23 @@ def were_addresses_spent_from(self, addresses): Check if a list of addresses was ever spent from, in the current epoch, or in previous epochs. - :param addresses: + If an address has a pending transaction, it's also considered 'spent'. + + :param Iterable[Address] addresses: List of addresses to check. + :return: + ``dict`` with the following structure:: + + { + 'states': List[bool], + States of the specified addresses in the same order as + the values in the addresses parameter. A ``True`` value + means that the address has been spent from. + 'duration': int, + Number of milliseconds it took to complete the request. + } + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference#wereaddressesspentfrom @@ -543,6 +831,32 @@ class Iota(StrictIota): Implements the core API, plus additional wrapper methods for common operations. + :param AdapterSpec adapter: + URI string or BaseAdapter instance. + + :param Optional[Seed] seed: + Seed used to generate new addresses. + If not provided, a random one will be generated. + + .. note:: + This value is never transferred to the node/network. + + :param Optional[bool] testnet: + Whether to use testnet settings for this instance. + On the testnet, minimum weight magnitude is decreased, on mainnet + it is 14 by default. + + For more info on the Mainnet and the Devnet, visit + `the official docs site`. + + :param Optional[bool] local_pow: + Whether to perform proof-of-work locally by redirecting all calls + to :py:meth:`attach_to_tangle` to + `ccurl pow interface `_. + + See :ref:`Optional Local Pow` for more info and + :ref:`find out` how to use it. + References: - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference @@ -570,8 +884,11 @@ def broadcast_and_store(self, trytes): """ Broadcasts and stores a set of transaction trytes. + :param Iterable[TransactionTrytes] trytes: + Transaction trytes to broadcast and store. + :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'trytes': List[TransactionTrytes], @@ -592,11 +909,11 @@ def broadcast_bundle(self, tail_transaction_hash): It might be useful when transactions did not properly propagate, particularly in the case of large bundles. - :param tail_transaction_hash: + :param TransactionHash tail_transaction_hash: Tail transaction hash of the bundle. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'trytes': List[TransactionTrytes], @@ -622,30 +939,38 @@ def find_transaction_objects( A more extensive version of :py:meth:`find_transactions` that returns transaction objects instead of hashes. - Effectively, this is ``find_transactions`` + ``get_trytes`` + - converting the trytes into transaction objects. + Effectively, this is :py:meth:`find_transactions` + + :py:meth:`get_trytes` + converting the trytes into + transaction objects. + + It accepts the same parameters as :py:meth:`find_transactions`. - It accepts the same parameters as :py:meth:`find_transactions` + Find the transactions which match the specified input. + All input values are lists, for which a list of return values + (transaction hashes), in the same order, is returned for all + individual elements. Using multiple of these input fields returns the + intersection of the values. - :param bundles: + :param Optional[Iterable[BundleHash]] bundles: List of bundle IDs. - :param addresses: + :param Optional[Iterable[Address]] addresses: List of addresses. - :param tags: + :param Optional[Iterable[Tag]] tags: List of tags. - :param approvees: + :param Optional[Iterable[TransactionHash]] approvees: List of approvee transaction IDs. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'transactions': List[Transaction], List of Transaction objects that match the input. } + """ return extended.FindTransactionObjectsCommand(self.adapter)( bundles=bundles, @@ -663,10 +988,10 @@ def get_account_data(self, start=0, stop=None, inclusion_states=False, security_ This function is useful in getting all the relevant information of your account. - :param start: + :param int start: Starting key index. - :param stop: + :param Optional[int] stop: Stop before this index. Note that this parameter behaves like the ``stop`` attribute @@ -676,13 +1001,13 @@ def get_account_data(self, start=0, stop=None, inclusion_states=False, security_ If ``None`` (default), then this method will check every address until it finds one without any transfers. - :param inclusion_states: + :param bool inclusion_states: Whether to also fetch the inclusion states of the transfers. This requires an additional API call to the node, so it is disabled by default. - :param security_level: + :param Optional[int] security_level: Number of iterations to use when generating new addresses (see :py:meth:`get_new_addresses`). @@ -692,7 +1017,7 @@ def get_account_data(self, start=0, stop=None, inclusion_states=False, security_ :py:attr:`AddressGenerator.DEFAULT_SECURITY_LEVEL`. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'addresses': List[Address], @@ -708,6 +1033,7 @@ def get_account_data(self, start=0, stop=None, inclusion_states=False, security_ List of bundles with transactions to/from this account. } + """ return extended.GetAccountDataCommand(self.adapter)( seed=self.seed, @@ -723,11 +1049,11 @@ def get_bundles(self, transaction): Returns the bundle(s) associated with the specified transaction hash. - :param transaction: + :param TransactionHash transaction: Transaction hash. Must be a tail transaction. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'bundles': List[Bundle], @@ -761,11 +1087,11 @@ def get_inputs( addresses until :py:meth:`find_transactions` returns an empty result), or by providing a key range to search. - :param start: + :param int start: Starting key index. Defaults to 0. - :param stop: + :param Optional[int] stop: Stop before this index. Note that this parameter behaves like the ``stop`` attribute @@ -775,7 +1101,7 @@ def get_inputs( If ``None`` (default), then this method will not stop until it finds an unused address. - :param threshold: + :param Optional[int] threshold: If set, determines the minimum threshold for a successful result: @@ -795,7 +1121,7 @@ def get_inputs( If ``threshold`` is ``None`` (default), this method will return **all** inputs in the specified key range. - :param security_level: + :param Optional[int] security_level: Number of iterations to use when generating new addresses (see :py:meth:`get_new_addresses`). @@ -805,7 +1131,7 @@ def get_inputs( :py:attr:`AddressGenerator.DEFAULT_SECURITY_LEVEL`. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'inputs': List[Address], @@ -816,8 +1142,8 @@ def get_inputs( Aggregate balance from all matching addresses. } - Note that each Address in the result has its ``balance`` - attribute set. + Note that each :py:class:`Address` in the result has its + :py:attr:`Address.balance` attribute set. Example: @@ -850,19 +1176,21 @@ def get_latest_inclusion(self, hashes): Fetches the inclusion state for the specified transaction hashes, as of the latest milestone that the node has processed. - Effectively, this is ``getNodeInfo`` + ``getInclusionStates``. + Effectively, this is :py:meth:`get_node_info` + + :py:meth:`get_inclusion_states`. - :param hashes: - Iterable of transaction hashes. + :param Iterable[TransactionHash] hashes: + List of transaction hashes. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { "states": Dict[TransactionHash, bool] - Dict with one boolean per transaction hash in + ``dict`` with one boolean per transaction hash in ``hashes``. } + """ return extended.GetLatestInclusionCommand(self.adapter)(hashes=hashes) @@ -873,26 +1201,26 @@ def get_new_addresses( security_level=AddressGenerator.DEFAULT_SECURITY_LEVEL, checksum=False, ): - # type: (int, Optional[int], int, bool) -> dict + # type: (int, int, int, bool) -> dict """ Generates one or more new addresses from the seed. - :param index: + :param int index: The key index of the first new address to generate (must be - >= 1). + >= 0). - :param count: + :param int count: Number of addresses to generate (must be >= 1). .. tip:: - This is more efficient than calling ``get_new_address`` + This is more efficient than calling :py:meth:`get_new_addresses` inside a loop. If ``None``, this method will progressively generate addresses and scan the Tangle until it finds one that has no transactions referencing it. - :param security_level: + :param int security_level: Number of iterations to use when generating new addresses. Larger values take longer, but the resulting signatures are @@ -900,18 +1228,18 @@ def get_new_addresses( This value must be between 1 and 3, inclusive. - :param checksum: + :param bool checksum: Specify whether to return the address with the checksum. Defaults to ``False``. :return: - Dict with the following structure:: + ``dict`` with the following structure:: - { - 'addresses': List[Address], - Always a list, even if only one address was - generated. - } + { + 'addresses': List[Address], + Always a list, even if only one address was + generated. + } References: @@ -934,17 +1262,17 @@ def get_transaction_objects( Fetches transaction objects from the Tangle given their transaction IDs (hashes). - Effectively, this is ``get_trytes`` + + Effectively, this is :py:meth:`get_trytes` + converting the trytes into transaction objects. Similar to :py:meth:`find_transaction_objects`, but accepts - list of trnsaction hashes as input. + list of transaction hashes as input. - :param hashes: + :param Iterable[TransactionHash] hashes: List of transaction IDs (transaction hashes). :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'transactions': List[Transaction], @@ -960,10 +1288,10 @@ def get_transfers(self, start=0, stop=None, inclusion_states=False): """ Returns all transfers associated with the seed. - :param start: + :param int start: Starting key index. - :param stop: + :param Optional[int] stop: Stop before this index. Note that this parameter behaves like the ``stop`` attribute @@ -973,14 +1301,14 @@ def get_transfers(self, start=0, stop=None, inclusion_states=False): If ``None`` (default), then this method will check every address until it finds one without any transfers. - :param inclusion_states: + :param bool inclusion_states: Whether to also fetch the inclusion states of the transfers. This requires an additional API call to the node, so it is disabled by default. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'bundles': List[Bundle], @@ -1009,25 +1337,25 @@ def is_promotable( # type: (Iterable(TransactionHash)] -> dict """ Checks if tail transaction(s) is promotable by calling - :py:meth:`check_consistency` and verifying that `attachmentTimestamp` + :py:meth:`check_consistency` and verifying that ``attachmentTimestamp`` is above a lower bound. Lower bound is calculated based on number of milestones issued since transaction attachment. - :param tails: + :param Iterable(TransactionHash) tails: List of tail transaction hashes. :return: The return type mimics that of :py:meth:`check_consistency`. - Dict with the following structure:: + ``dict`` with the following structure:: { 'promotable': bool, - If true, all tails are promotable. If false, see `info` - field. + If ``True``, all tails are promotable. If ``False``, see + `info` field. - 'info': Optional(List[String]) - If `promotable` is false, this contains info about what + 'info': Optional(List[Text]) + If `promotable` is ``False``, this contains info about what went wrong. } @@ -1052,10 +1380,10 @@ def prepare_transfer( generating the correct bundle, as well as choosing and signing the inputs (for value transfers). - :param transfers: + :param Iterable[ProposedTransaction] transfers: Transaction objects to prepare. - :param inputs: + :param Optional[Iterable[Address]] inputs: List of addresses used to fund the transfer. Ignored for zero-value transfers. @@ -1064,14 +1392,14 @@ def prepare_transfer( many transfers you've already sent with your seed, this process could take awhile. - :param change_address: + :param Optional[Address] change_address: If inputs are provided, any unspent amount will be sent to this address. If not specified, a change address will be generated automatically. - :param security_level: + :param Optional[int] security_level: Number of iterations to use when generating new addresses (see :py:meth:`get_new_addresses`). @@ -1081,7 +1409,7 @@ def prepare_transfer( :py:attr:`AddressGenerator.DEFAULT_SECURITY_LEVEL`. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'trytes': List[TransactionTrytes], @@ -1111,21 +1439,21 @@ def promote_transaction( """ Promotes a transaction by adding spam on top of it. - :param transaction: + :param TransactionHash transaction: Transaction hash. Must be a tail transaction. - :param depth: + :param int depth: Depth at which to attach the bundle. Defaults to 3. - :param min_weight_magnitude: + :param Optional[int] min_weight_magnitude: Min weight magnitude, used by the node to calibrate Proof of Work. If not provided, a default value will be used. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'bundle': Bundle, @@ -1153,21 +1481,21 @@ def replay_bundle( associated with the transaction and then replays the bundle by attaching it to the Tangle. - :param transaction: + :param TransactionHash transaction: Transaction hash. Must be a tail. - :param depth: + :param int depth: Depth at which to attach the bundle. Defaults to 3. - :param min_weight_magnitude: + :param Optional[int] min_weight_magnitude: Min weight magnitude, used by the node to calibrate Proof of Work. If not provided, a default value will be used. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'trytes': List[TransactionTrytes], @@ -1202,31 +1530,31 @@ def send_transfer( attaches the bundle to the Tangle, and broadcasts and stores the transactions. - :param transfers: + :param Iterable[ProposedTransaction] transfers: Transfers to include in the bundle. - :param depth: + :param int depth: Depth at which to attach the bundle. Defaults to 3. - :param inputs: + :param Optional[Iterable[Address]] inputs: List of inputs used to fund the transfer. Not needed for zero-value transfers. - :param change_address: + :param Optional[Address] change_address: If inputs are provided, any unspent amount will be sent to this address. If not specified, a change address will be generated automatically. - :param min_weight_magnitude: + :param Optional[int] min_weight_magnitude: Min weight magnitude, used by the node to calibrate Proof of Work. If not provided, a default value will be used. - :param security_level: + :param Optional[int] security_level: Number of iterations to use when generating new addresses (see :py:meth:`get_new_addresses`). @@ -1236,7 +1564,7 @@ def send_transfer( :py:attr:`AddressGenerator.DEFAULT_SECURITY_LEVEL`. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'bundle': Bundle, @@ -1266,21 +1594,21 @@ def send_trytes(self, trytes, depth=3, min_weight_magnitude=None): Attaches transaction trytes to the Tangle, then broadcasts and stores them. - :param trytes: + :param Iterable[TransactionTrytes] trytes: Transaction encoded as a tryte sequence. - :param depth: + :param int depth: Depth at which to attach the bundle. Defaults to 3. - :param min_weight_magnitude: + :param Optional[int] min_weight_magnitude: Min weight magnitude, used by the node to calibrate Proof of Work. If not provided, a default value will be used. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'trytes': List[TransactionTrytes], @@ -1315,16 +1643,17 @@ def is_reattachable(self, addresses): successfully used in a different transaction, and as such you should no longer replay the transaction. - :param addresses: + :param Iterable[Address] addresses: List of addresses. :return: - Dict with the following structure:: + ``dict`` with the following structure:: { 'reattachable': List[bool], Always a list, even if only one address was queried. } + """ return extended.IsReattachableCommand(self.adapter)( addresses=addresses @@ -1338,20 +1667,21 @@ def traverse_bundle(self, tail_hash): Recursively traverse the Tangle, collecting transactions until we hit a new bundle. - This method is (usually) faster than ``findTransactions``, and + This method is (usually) faster than :py:meth:`find_transactions`, and it ensures we don't collect transactions from replayed bundles. - :param tail_hash: + :param TransactionHash tail_hash: Tail transaction hash of the bundle. :return: - Dict with the following structure:: + ``dict`` with the following structure:: + + { + 'bundle': List[Bundle], + List of matching bundles. Note that this value is + always a list, even if only one bundle was found. + } - { - 'bundle': List[Bundle], - List of matching bundles. Note that this value is - always a list, even if only one bundle was found. - } """ return extended.TraverseBundleCommand(self.adapter)( transaction=tail_hash diff --git a/iota/transaction/types.py b/iota/transaction/types.py index b92da789..a76bea19 100644 --- a/iota/transaction/types.py +++ b/iota/transaction/types.py @@ -34,6 +34,9 @@ class Fragment(TryteString): A signature/message fragment in a transaction. """ LEN = FRAGMENT_LENGTH + """ + Length of a fragment in trytes. + """ def __init__(self, trytes): # type: (TrytesCompatible) -> None @@ -57,6 +60,9 @@ class TransactionTrytes(TryteString): A TryteString representation of a Transaction. """ LEN = 2673 + """ + Length of a transaction in trytes. + """ def __init__(self, trytes): # type: (TrytesCompatible) -> None @@ -80,6 +86,9 @@ class Nonce(TryteString): A TryteString that acts as a transaction nonce. """ LEN = 27 + """ + Length of a nonce in trytes. + """ def __init__(self, trytes): # type: (TrytesCompatible) -> None From 9fa1727e2a9bdca5b2bc0cb4e43eba3763c034a3 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Thu, 14 Nov 2019 10:03:51 +0100 Subject: [PATCH 07/20] Add docs build step to Travis CI --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 029d833d..33763b03 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,12 @@ python: - '3.6' - '3.7' install: - - pip install . + - pip install .[docs-builder] - pip install docutils pygments # Used to check package metadata. script: - python setup.py check --strict --metadata --restructuredtext - nosetests + - cd docs && make html && cd .. # Build documentation. deploy: on: python: '3.7' From 22b4f46249166674b13f202219ac7f139b3370b7 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Thu, 14 Nov 2019 17:44:23 +0100 Subject: [PATCH 08/20] add_signature_or_message method for ProposedBundle The method can insert custom messages or signatures into transactions in a ProposedBundle object. Must be called before bundle is finalized. --- docs/types.rst | 4 + iota/transaction/creation.py | 66 ++++++++ test/transaction/creation_test.py | 240 ++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) diff --git a/docs/types.rst b/docs/types.rst index 559b5318..6d801d87 100644 --- a/docs/types.rst +++ b/docs/types.rst @@ -347,6 +347,10 @@ bundles, listed in the order that they should be invoked: - ``send_unspent_inputs_to: (Address) -> None``: Specifies the address that will receive unspent IOTAs. The ``ProposedBundle`` will use this to create the necessary change transaction, if necessary. +- ``add_signature_or_message: (List[Fragment], int) -> None``: + Adds signature or message fragments to transactions in the bundle + starting from ``start_index``. Must be called before the bundle is + finalized. - ``finalize: () -> None``: Prepares the bundle for PoW. Once this method is invoked, no new transactions may be added to the bundle. - ``sign_inputs: (KeyGenerator) -> None``: Generates the necessary diff --git a/iota/transaction/creation.py b/iota/transaction/creation.py index a2579077..edc900eb 100644 --- a/iota/transaction/creation.py +++ b/iota/transaction/creation.py @@ -488,3 +488,69 @@ def _create_input_transactions(self, addy): # Note zero value; this is a meta transaction. value=0, )) + + def add_signature_or_message( + self, + fragments, # type: Iterable[Fragment] + start_index=0 # type: Optional[int] + ): + # type: (...) -> None + """ + Adds signature/message fragments to transactions in the bundle + starting at start_index. If a transaction already has a fragment, + it will be overwritten. + + :param Iterable[Fragment] fragments: + List of fragments to add. + Use [Fragment(...),Fragment(...),...] to create this argument. + Fragment() accepts any TryteString compatible type, or types that + can be converted to TryteStrings (bytearray, unicode string, etc.). + If the payload is less than :py:attr:`FRAGMENT_LENGTH`, it will pad + it with 9s. + + :param int start_index: + Index of transaction in bundle from where addition shoudl start. + """ + if self.hash: + raise RuntimeError('Bundle is already finalized.') + + if not isinstance(fragments, Iterable): + raise TypeError('Expected iterable for `fragments`, but got {type} instead.'.format( + type=fragments.__class__.__name__ + )) + + if not all(isinstance(x, Fragment) for x in fragments): + raise TypeError( + 'Expected `fragments` to contain only Fragment objects, but got {types} instead.'.format( + types=[x.__class__.__name__ for x in fragments], + ) + ) + + if not isinstance(start_index, int): + raise TypeError('Expected int for `start_index`, but got {type} instead.'.format( + type=start_index.__class__.__name__, + )) + + length = len(fragments) + + if not length: + raise ValueError('Empty list provided for `fragments`.') + + if start_index < 0 or start_index > len(self) - 1: + raise ValueError('Wrong start_index provided: {index}'.format( + index=start_index)) + + if start_index + length > len(self): + raise ValueError('Can\'t add {length} fragments starting from index ' + '{start}: There are only {count} transactions in ' + 'the bundle.'.format( + length=length, + start=start_index, + count=len(self), + )) + + for i in range(length): + # Bundle is not finalized yet, therefore we should fill the message + # field. This will be put into signature_message_fragment upon + # finalization. + self._transactions[start_index + i].message = fragments[i] diff --git a/test/transaction/creation_test.py b/test/transaction/creation_test.py index b0857952..9112593f 100644 --- a/test/transaction/creation_test.py +++ b/test/transaction/creation_test.py @@ -881,3 +881,243 @@ def test_create_tag_from_string(self): ) self.assertEqual(type(transaction.tag), type(Tag(b''))) + + def test_add_signature_or_message(self): + """ + Add a fragment to a transaction. + """ + # Add a transaction + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + b'TESTVALUE9DONTUSEINPRODUCTION99999QARFLF' + b'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + message = TryteString.from_unicode('This should be overwritten'), + value = 0, + )) + custom_msg = \ + 'The early bird gets the worm, but the custom-msg gets into the bundle.' + custom_fragment = Fragment.from_unicode(custom_msg) + + # Before finalization, the method adds to message field... + self.bundle.add_signature_or_message([custom_fragment]) + self.assertEqual( + self.bundle._transactions[0].message, + custom_fragment + ) + + # ... because upon finalization, this is translated into + # signature_message_fragment field. + self.bundle.finalize() + self.assertEqual( + self.bundle._transactions[0].signature_message_fragment, + custom_fragment + ) + + # Do we have the right text inside? + self.assertEqual( + self.bundle.get_messages()[0], + custom_msg + ) + + def test_add_signature_or_messagee_multiple(self): + """ + Add multiple fragments. + """ + # Add 3 transactions to the bundle, For convenience, we use + # 3 different addresses, so they are not grouped together and + # bundle.get_messages() returns a list of messages mapping to + # the 3 transactions. + for i in ['A', 'B', 'C']: + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + 'TESTVALUE' + i + 'DONTUSEINPRODUCTION99999QARFLF' + 'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + message = TryteString.from_unicode('This should be overwritten'), + value = 0, + )) + + fragment1 = Fragment.from_unicode('This is the first fragment.') + fragment2 = Fragment.from_unicode('This is the second fragment.') + + self.bundle.add_signature_or_message([fragment1, fragment2]) + + bundle_fragments = [] + for tx in self.bundle: + bundle_fragments.append(tx.message) + + self.assertListEqual( + bundle_fragments, + [fragment1, fragment2, TryteString.from_unicode('This should be overwritten')] + ) + + self.bundle.finalize() + + bundle_fragments_unicode = [] + for tx in self.bundle: + bundle_fragments_unicode.append(tx.signature_message_fragment.decode()) + + self.assertListEqual( + bundle_fragments_unicode, + [fragment1.decode(), fragment2.decode(), 'This should be overwritten'] + ) + + def test_add_signature_or_message_multiple_offset(self): + """ + Add multiple fragments with offset. + """ + # Add 3 transactions to the bundle. + for i in ['A', 'B', 'C']: + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + 'TESTVALUE' + i + 'DONTUSEINPRODUCTION99999QARFLF' + 'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + message = TryteString.from_unicode('This should be overwritten'), + value = 0, + )) + + fragment1 = Fragment.from_unicode('This is the first fragment.') + fragment2 = Fragment.from_unicode('This is the second fragment.') + + self.bundle.add_signature_or_message([fragment1, fragment2], 1) + + bundle_fragments = [] + for tx in self.bundle: + bundle_fragments.append(tx.message) + + self.assertListEqual( + bundle_fragments, + [TryteString.from_unicode('This should be overwritten'), fragment1, fragment2] + ) + + self.bundle.finalize() + + bundle_fragments_unicode = [] + for tx in self.bundle: + bundle_fragments_unicode.append(tx.signature_message_fragment.decode()) + + self.assertListEqual( + bundle_fragments_unicode, + ['This should be overwritten', fragment1.decode(), fragment2.decode()] + ) + + def test_add_signature_or_message_too_long_fragments(self): + """ + Trying to add too many fragments to a bundle, when there aren't enough + transactions to hold them. + """ + # Add 3 transactions to the bundle. + for i in ['A', 'B', 'C']: + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + 'TESTVALUE' + i + 'DONTUSEINPRODUCTION99999QARFLF' + 'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + message= TryteString.from_unicode('This should be overwritten'), + value = 0, + )) + + fragment1 = Fragment.from_unicode('This is the first fragment.') + # 4 fragments, 3 txs in bundle + fragments = [fragment1] * 4 + + with self.assertRaises(ValueError): + self.bundle.add_signature_or_message(fragments) + + # Length is okay, but overflow because of offset + fragments = [fragment1] * 3 + + with self.assertRaises(ValueError): + self.bundle.add_signature_or_message(fragments,start_index=1) + + def test_add_signature_or_message_invalid_start_index(self): + """ + Attempting to add fragments to a bundle, but `start_index` is invalid. + """ + # Add 3 transactions to the bundle. + for i in ['A', 'B', 'C']: + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + 'TESTVALUE' + i + 'DONTUSEINPRODUCTION99999QARFLF' + 'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + message = TryteString.from_unicode('This should be overwritten'), + value = 0, + )) + + fragment1 = Fragment.from_unicode('This is the first fragment.') + + with self.assertRaises(ValueError): + self.bundle.add_signature_or_message([fragment1], start_index=-1) + + with self.assertRaises(ValueError): + self.bundle.add_signature_or_message([fragment1], start_index=3) + + with self.assertRaises(TypeError): + self.bundle.add_signature_or_message([fragment1], 'not an int') + + def test_add_signature_or_message_empty_list(self): + """ + Try to add an empty list of fragments. + """ + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + 'TESTVALUE9DONTUSEINPRODUCTION99999QARFLF' + 'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + value = 0, + )) + + with self.assertRaises(ValueError): + self.bundle.add_signature_or_message([]) + + def test_add_signature_or_message_wrong_types(self): + """ + Try add signatures/messages with wrong type. + """ + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + 'TESTVALUE9DONTUSEINPRODUCTION99999QARFLF' + 'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + value = 0, + )) + + with self.assertRaises(TypeError): + self.bundle.add_signature_or_message('Not a list') + + with self.assertRaises(TypeError): + self.bundle.add_signature_or_message(['List but not Fragment']) + + def test_add_signature_or_message_finalized_bundle(self): + """ + Try to call the method on a finalized bundle. + """ + self.bundle.add_transaction(ProposedTransaction( + address = + Address( + b'TESTVALUE9DONTUSEINPRODUCTION99999QARFLF' + b'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + message = TryteString.from_unicode('This should be overwritten'), + value = 0, + )) + + custom_msg = \ + 'The early bird gets the worm, but the custom-msg gets into the bundle.' + custom_fragment = Fragment.from_unicode(custom_msg) + + # Finalize the bundle, no further changes should be permitted. + self.bundle.finalize() + + with self.assertRaises(RuntimeError): + self.bundle.add_signature_or_message([custom_fragment]) \ No newline at end of file From d6453e524953180eb5a119db49d76d83cc4d3832 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Fri, 15 Nov 2019 13:50:40 +0100 Subject: [PATCH 09/20] Support adding/removing address checksums Two new methods in Address class: - add_checksum: appends a valid checksum to the Address object. - remove_checksum: slices off the checksum from an Address object. --- iota/types.py | 22 ++++++++++++++ test/types_test.py | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/iota/types.py b/iota/types.py index 44fc58a1..beeab33e 100644 --- a/iota/types.py +++ b/iota/types.py @@ -870,6 +870,28 @@ def _generate_checksum(self): return AddressChecksum.from_trits(checksum_trits[-checksum_length:]) + def add_checksum(self): + # type: () -> None + """ + Add checksum to :py:class:`Address` object. + """ + if self.is_checksum_valid(): + # Address already has a valid checksum. + return + + # Fill checksum attribute + self.checksum = self._generate_checksum() + + # Add generated checksum to internal buffer. + self._trytes = self._trytes + self.checksum._trytes + + def remove_checksum(self): + # type: () -> None + """ + Remove checksum from :py:class:`Address` object. + """ + self.checksum = None + self._trytes = self._trytes[:self.LEN] class AddressChecksum(TryteString): """ diff --git a/test/types_test.py b/test/types_test.py index 3ab0eb79..bdf4a103 100644 --- a/test/types_test.py +++ b/test/types_test.py @@ -1049,6 +1049,81 @@ def test_with_checksum_attributes(self): self.assertEqual(checked.key_index, 42) self.assertEqual(checked.balance, 86) + def test_add_checksum(self): + """ + Checksum is added to an address without it. + """ + addy = Address( + trytes = + b'ZKIUDZXQYQAWSHPKSAATJXPAQZPGYCDCQDRSMWWCGQJNI' + b'PCOORMDRNREDUDKBMUYENYTFVUNEWDBAKXMV' + ) + + addy.add_checksum() + + self.assertTrue(addy.is_checksum_valid()) + self.assertTrue(len(addy) == Address.LEN + AddressChecksum.LEN) + + def test_add_checksum_second_time(self): + """ + Checksum is added to an address that already has. + """ + addy = Address( + trytes = + b'ZKIUDZXQYQAWSHPKSAATJXPAQZPGYCDCQDRSMWWCGQJNI' + b'PCOORMDRNREDUDKBMUYENYTFVUNEWDBAKXMVJJJGBARPB' + ) + + addy.add_checksum() + + self.assertTrue(addy.is_checksum_valid()) + self.assertTrue(len(addy) == Address.LEN + AddressChecksum.LEN) + + self.assertEqual( + addy, + Address( + trytes = + b'ZKIUDZXQYQAWSHPKSAATJXPAQZPGYCDCQDRSMWWCGQJNI' + b'PCOORMDRNREDUDKBMUYENYTFVUNEWDBAKXMVJJJGBARPB' + ) + ) + + def test_remove_checksum(self): + """ + Checksum is removed from an address. + """ + addy = Address( + trytes = + b'ZKIUDZXQYQAWSHPKSAATJXPAQZPGYCDCQDRSMWWCGQJNI' + b'PCOORMDRNREDUDKBMUYENYTFVUNEWDBAKXMVJJJGBARPB' + ) + + self.assertTrue(addy.is_checksum_valid()) + self.assertTrue(len(addy) == Address.LEN + AddressChecksum.LEN) + + addy.remove_checksum() + + self.assertFalse(addy.is_checksum_valid()) + self.assertTrue(len(addy) == Address.LEN) + + def test_remove_checksum_second_time(self): + """ + `remove_checksum` is called on an Address that does not have a checksum. + """ + addy = Address( + trytes = + b'ZKIUDZXQYQAWSHPKSAATJXPAQZPGYCDCQDRSMWWCGQJNI' + b'PCOORMDRNREDUDKBMUYENYTFVUNEWDBAKXMV' + ) + + self.assertFalse(addy.is_checksum_valid()) + self.assertTrue(len(addy) == Address.LEN) + + addy.remove_checksum() + + self.assertFalse(addy.is_checksum_valid()) + self.assertTrue(len(addy) == Address.LEN) + # noinspection SpellCheckingInspection class AddressChecksumTestCase(TestCase): From 7c647c4c00deb1c216badd5854bdeba84c2c67a2 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Mon, 18 Nov 2019 16:20:27 +0100 Subject: [PATCH 10/20] Fine-tune is_promotable response dictionary Make the response dict consistent with check_consistency. Delete 'info' key in response if 'promotable' is True. --- iota/api.py | 2 ++ iota/commands/extended/is_promotable.py | 3 ++- test/commands/extended/is_promotable_test.py | 1 - 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/iota/api.py b/iota/api.py index 47dcb7b8..9b12bf79 100644 --- a/iota/api.py +++ b/iota/api.py @@ -1357,6 +1357,8 @@ def is_promotable( 'info': Optional(List[Text]) If `promotable` is ``False``, this contains info about what went wrong. + Note that when 'promotable' is ``True``, 'info' does not + exist. } diff --git a/iota/commands/extended/is_promotable.py b/iota/commands/extended/is_promotable.py index 11f4bb83..cda97691 100644 --- a/iota/commands/extended/is_promotable.py +++ b/iota/commands/extended/is_promotable.py @@ -93,8 +93,9 @@ def _execute(self, request): response['promotable'] = response['promotable'] and is_within # If there are no problems, we don't need 'info' field + # Delete info field to make it consistent with check_consistency repsonse. if response['promotable']: - response['info'] = None + del response['info'] return response diff --git a/test/commands/extended/is_promotable_test.py b/test/commands/extended/is_promotable_test.py index 6710a35f..ad991789 100644 --- a/test/commands/extended/is_promotable_test.py +++ b/test/commands/extended/is_promotable_test.py @@ -317,7 +317,6 @@ def test_happy_path(self): { 'promotable': True, - 'info': None, } ) From 16226e8e2f3e5e3aa465d90020978d1501f8d31e Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Wed, 20 Nov 2019 13:40:43 +0100 Subject: [PATCH 11/20] docs: add basic concepts page Add a page describing basic IOTA concepts with references to the official docs site. --- docs/basic_concepts.rst | 178 +++++++++++++++++++++++++++++++ docs/images/bundle-structure.png | Bin 0 -> 17346 bytes docs/index.rst | 1 + docs/types.rst | 8 -- 4 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 docs/basic_concepts.rst create mode 100644 docs/images/bundle-structure.png diff --git a/docs/basic_concepts.rst b/docs/basic_concepts.rst new file mode 100644 index 00000000..b2fca725 --- /dev/null +++ b/docs/basic_concepts.rst @@ -0,0 +1,178 @@ +Basic Concepts +============== + +Before diving into the API, it's important to understand the fundamental +data types of IOTA. + +The `official IOTA documentation site`_ gives a good and in-depth explanation of the +concepts used in IOTA. PyOTA documentation will try to give references to the +official site wherever possible. + +Ternary +------- + +IOTA uses the `ternary numerical system`_ to represent data. The smallest unit of +information is a ``trit``, that can have a value of -1, 0 or 1 in a balanced +ternary system. A combination of 3 ``trits`` equals one ``tryte``, therefore a +``tryte`` can have 3 * 3 * 3 = 27 different values. + +To represent a ``tryte``, IOTA encodes these 27 values into characters based on +the `tryte alphabet`_. + +.. py:currentmodule:: iota + +In PyOTA, ``trits`` are represented as a sequence of numerical values (``List[int]``) +while trytes have their own class called :py:class:`TryteString`. + +IOTA token +---------- + +The `IOTA token`_ is a `unit`_ of value that can be transferred over an IOTA +network through `transfer bundles`_. + +The IOTA token was launched on the Mainnet in June 2017. At this point, the +nodes in the network were hard-coded with a total supply of 2,779,530,283 277,761. +This large supply allows each of the billions of devices, which are expected to +be a part of the Internet of Things, to have its own wallet and transact +micropayments with other devices. + +Seed +---- + +`Seed`_ in IOTA is your unique password. It is the digital key that unlocks +your safe that holds your tokens, or proves the ownership of messages. + +Seeds in PyOTA are always 81 trytes long and may only contain characters from +the `tryte alphabet`_. + +.. warning:: + Treat your seed(s) the same as you would the password for any other financial service. + Never share your seed with anyone, and never use online generators to create a seed. + The library can help you to create your own locally and it does not require + internet connection: :py:meth:`iota.crypto.Seed.random`. + +For PyOTA-specific implementation details on seeds, see :py:class:`crypto.Seed`. + +Address +------- + +To send or receive any transaction (let them be zero-value or value transacitons) +in IOTA, you will need to specify an `address`_. An address is like a physical mailbox +on your entrance door: anyone can drop things in your mailbox (send you messages +or tokens), but only you can empty it (withdraw tokens). + +.. warning:: + Addresses should not be re-used once they are `spent from`_. + **You can receive as many transactions to an address as you wish, but only + spend from that address once.** + +Addresses are generated from your seed through cryptographic functions. There +are 9\ :sup:`57`\ different addresses that one might generate from a seed, +which is quite a lot. Given your ``seed``, the ``index`` and ``security level`` +parameters specify which address will be generated from it. The process is +deterministic, meaning that same input paramteres always generate the same address. + +Addresses are 81 trytes long and may contain extra 9 trytes for checksum. +The checksum may be used to verify that an address is in fact a valid IOTA address. + +For-PyOTA specific implementation details on addresses, see :py:class:`Address`. + +Transaction +----------- + + *A transaction is a single transfer instruction that can either withdraw IOTA + tokens from an address, deposit them into an address, or have zero-value + (contain data, a message, or a signature). If you want to send anything to an + IOTA network, you must send it to a node as a transaction.* + + --- from the `official IOTA documentation site`_ + +Transactions are always 2673 trytes long and their `structure`_ is defined by +the protocol. They can be classified into three categories: + +- `Input transaction`_: A transaction that withdraws tokens from an address. +- `Output transaction`_: A transaction that deposits tokens to an address. +- `Zero-value transaction`_: A transaction that has 0 value and might carry messages + or signatures. + +Depending on the type of the transaction, different fields are required to be filled. + +A transaction's unique identifier in IOTA is the :py:class:`TransactionHash`, +that is generated from the trytes of the transaction. If any trytes change +in the transaction, the returning transaction hash would alter. This way, transaction +hashes ensure the immutability of the Tangle. + +To become accepted by the network, a transaction has to be attached to the Tangle. +The attachment process means that the transaction should reference two unconfirmed +transactions (tips) in the Tangle and do a small proof-of-work. This process might +be performed by a node, or by using the local proof-of-work feature of the `client +libraries`_. + +For PyOTA-specific implementation details on transactions, see :py:class:`Transaction` +and :py:class:`ProposedTransaction`. + +Bundle +------ + + *A bundle is a group of transactions that rely on each other's validity. + For example, a transaction that deposits IOTA tokens into an address relies on + another transaction to withdraw those IOTA tokens from another address. + Therefore, those transactions must be in the same bundle.* + + --- from the `official IOTA documentation site`_ + +In other words, a bundle is collection of transactions, treated as an atomic unit when +attached to the Tangle. + +.. note:: + Unlike a block in a blockchain, bundles are not first-class + citizens in IOTA; only transactions get stored in the Tangle. + + Instead, bundles must be inferred by following linked transactions + with the same bundle hash. + +Transactions in the bundle are linked together through their ``trunkTransaction`` +fields, furthermore they are indexed within the bundle and contain a ``bundleHash`` +field that is a `unique identifier for the bundle`_. + +.. figure:: images/bundle-structure.png + :scale: 100 % + :alt: Bundle structure with four transactions. + + Structure of a bundle with four transactions. Numbers in brackets denote + (``currentIndex``, ``lastIndex``) fields. Head of the bundle has index 0, + while tail has index 3. + +Read more about `how bundles are structured`_. + +Bundles can be classified into two categories: + +- `Transfer bundles`_: Bundles that contain input and output transactions. A bundle + always has to be balanced, meaning that input transaction values should equal + to output transaction values. +- `Zero-value bundles`_: Bundles that contain only zero-value transactions. + +For PyOTA-specific implementation details on bundles, see :py:class:`Bundle` +and :py:class:`ProposedBundle`. + +Now that you are familiar with some basic IOTA concepts, it is time to explore +how PyOTA implements these and how you can work with them. + +.. _official IOTA documentation site: https://docs.iota.org/docs/getting-started/0.1/introduction/overview +.. _ternary numerical system: https://docs.iota.org/docs/getting-started/0.1/introduction/ternary +.. _tryte alphabet: https://docs.iota.org/docs/getting-started/0.1/introduction/ternary#tryte-encoding +.. _iota token: https://docs.iota.org/docs/getting-started/0.1/clients/token +.. _iota network: https://docs.iota.org/docs/getting-started/0.1/network/iota-networks +.. _unit: https://docs.iota.org/docs/getting-started/0.1/clients/token#units-of-iota-tokens +.. _seed: https://docs.iota.org/docs/getting-started/0.1/clients/seeds +.. _address: https://docs.iota.org/docs/getting-started/0.1/clients/addresses +.. _spent from: https://docs.iota.org/docs/getting-started/0.1/clients/addresses#spent-addresses +.. _structure: https://docs.iota.org/docs/getting-started/0.1/transactions/transactions#structure-of-a-transaction +.. _input transaction: https://docs.iota.org/docs/getting-started/0.1/transactions/transactions#input-transactions +.. _output transaction: https://docs.iota.org/docs/getting-started/0.1/transactions/transactions#output-transactions +.. _zero-value transaction: https://docs.iota.org/docs/getting-started/0.1/transactions/transactions#zero-value-transactions +.. _client libraries: https://docs.iota.org/docs/client-libraries/0.1/introduction/overview +.. _how bundles are structured: https://docs.iota.org/docs/getting-started/0.1/transactions/bundles +.. _unique identifier for the bundle: https://docs.iota.org/docs/getting-started/0.1/transactions/bundles#bundle-hash +.. _transfer bundles: https://docs.iota.org/docs/getting-started/0.1/transactions/bundles#transfer-bundles +.. _zero-value bundles: https://docs.iota.org/docs/getting-started/0.1/transactions/bundles#zero-value-bundle diff --git a/docs/images/bundle-structure.png b/docs/images/bundle-structure.png new file mode 100644 index 0000000000000000000000000000000000000000..523549556d5f7cb0d2ef175bf06fe5c76c76d964 GIT binary patch literal 17346 zcmb8Xby!y2w>1o+(gF(7B`GOVf^>-@-7S)m(j5v)x3qM(bc0GtH`3A#(hc9_d9L$a zzw15cyw~^Le{5xQv-e(W%{k^6W3K!5my>yciAIEmfPjGcQc_F-0pV5?0spk2 z4^9+F9jUZ92p38PZr(R5ljN$3_)W{;qxFg*GK(WPE`C;jd6=UXReO2Q<*Fn{j&ATh zb#wfWcmKy}uT8|8U;cBE|91L6?)dLT{(bj)FhSG01L_|a)so0+e zOieg6oSk_(@s|QcUihcC$gkRbLKf;f(Qi6DG9uqduZzq3ii9Ra#M5fE=H?0i z8N~l`|NrIme~kG5IzRt8i2r#aQuG*J?J{I`wV~9%O63n+>q1M+@*NX~GrlLp-Bx-26E^^J)Sv+*XgeS{1~L zQiVIaTE%5Bf=7nPY|hkpSmNH&I_rkH?ym5c5Ye*7^i&kt4!f3Hyq-M|d@8aMAHFx0 zfv);=@6`zJ(YGt7a`i}ucM9(X*Jd+<6b1)6`t3U2OKIap5LUGCeW#`ysl)Hg8$a6H zpFGyi4$xAV7}n`6?w^nPlIy_bc$}2Ws?0=J=}R4*;8YMlnJ*JAMcTHp{q0#0cd*>u zoZQ@sP5k$Y1ekZd2p=rboC#7kS}a%AJoaNzICEksRFt|MJt2k4;TQU4v$c;4P^!2E7hC83)~O@0v%LxT zbmoowk`lSCW@>C#7#J8P9Jp69ZgbYh_-Ij)XOz!KG@i$UQj;;Z zgCmbgO^-ImmH0?VNRGCrH5C*F9iDS0cj4jjBq!3(gx<2D9`R!B4mre2M$5AiSQisH zF0?vcG_Lla33z}uPKjZ9-2G>-W*ikqDd3msg+T;4qejqnR9N$z?KtZE0s=<4Y zH;TN^uR5Xj-TmySX2=snhUS^`YnT%`D7kxf7O$`dRV3XyXLFwqrP2r!ve>*jQ#>67y3me-j z>|}Rq9ZUF1v9^IOp;(Gsbh02*u>L^8<7*SOwBDyD`CS?~rN{x%(W4*lpwG|GuhWJU zs;*AqX1feN&tqyCiy203{NT?1LQ4XNDI_eCe#SeUCj0ygg?u4TY^%k=N44??=X6TO zX>Je8cwPS(zX)VdG!J=efOVfrBItL6tE08G_2ky_@M(g&8Eapvf`rj+)eg%=G~x9p zGr#ZUQ~O#QVC`5tdT7Z^@2x6YnIWK08<|^SC@KC}4{FMJ864W49t9iB|VxAA4Hx zg_el0494tgeB)=afjfu|yH~Ux9unrS%~cK^jayEnCdhn-dAb=t7psjUi7P7F&eS_Y z%0wNQlsiKv*raFjhOb^7av#1VZ6lu4i&}gVvYATQ%OXO_-Il-pmi?phb==2JtQ-c$ z#xHLA{7kuyvo;aGLhRF2-D9Zx3-Mb(&Htr@UU%q=WXDf8@2Oik}1BadDEdi?n8 z>@4qa4FYSSYS`r6JD%sy+uPgo^YS{yPZcmjEeF&ON%G>q@JpgSqiZRwNx%e{ZR4gIQNudE^fI!(ol;^77&C?t+<5&mA^T&Uc!)*<8cERaMQF8Dsf2zdPSd zJ>wJ3n?&CzWW~(eJKpACw`Zii$Q@n2pWUIm+HGMNSQ)D#DnbN=+3=|5A&*?&7FMC=k>o zdE|wYz}YN2%yF1^l{@kMQGLAU`awB`4`266V>>r$b!JO!FSaUQ-yf=)c{3+GW&1nm z^uZF5#d}NpQYe(8S4Ka$A%V(vezH%_)ai~y9KN~$i+0hQ5 z%xIcPK#!&qFI*FGr19~1(wzUU>W>+50!GSEZd(Y_k8tiBd(!aB=taFh-fF+YCc&=ExqM&WwRAC6(w zRt&wD+Mj4urs<`QUv+q_+%@s=NJ|)Qh?cer{Zf~Nj){pGw^@@@qwX*7+1EL$@sD3~9F)&c7J%n^A z&n-VEzi~%Y?sjOUbf)yQL&@@2-XHv4GLrMtj|T2o^o|gS*x|0@FviEn@2~WQ1_rvG z{7r3YYV!B@-{d3xvpU9w+~O#0Q}zgja+}K|&VERHcbh2kG|VOY;>v&bu7l>h|Md?s z%XQWx_Zflr*H`|W921@8>&;#Pja#nRuk z<}T8HuFm7Xm~{PvEr^E9GI2{vvVQjW7Iy&A^C0fVW`+&D`n52AIb@ensDT1($%g1eNteJzG@=c0l}eAWS7bH&B`2HzsmK2Cj1t8I{J z9bfagoi!Dy^m&TC9Si$sYUg$D2$GN8n1Zs;{g|oQ_iKZh;(-s>R#!VF@cumA#S_u0 z4&+)}V6;T~TYj2lDXZ6`cftI;z=pJKz;;)Fc~aSNft~6%r*snK{O{_K+T%||#yH_W z!cF<9UKgi$4N<>EETo_8r zaHU^_DxG?5Z)sjIWIOQY6iuCSzRl*dYReCA9Lg&tMpL1*69@C%XKZT{HqKM2IoaYb zkLAkUQx*B~2en0%)0qjZIk8Z>{-$-N9mV2%6j+U5?oZ`SQYVT$68V^S=eC%i)%Wbr zJ5Sd`a^{W16)#DyC@FDpD0P&IeN{241~i{8<+SAfbJ33PmMr%+i5sxKdJiKxW>`9h z>>CfChheQXJL$vNN!kQ`B8;#&o2y#rFhpXltw@wllzQ?B>s8$+^j;-vKPx~mlIBi` z^n9&sxLg{G$xrFkgZ4hpxsot(Eg`HqYJ%LY43Fi{o>JqmAFE5@fHV*cFRjn#f;0qN^5-yhEh@6D-X291ol z9+I^YPXR=|IO^}77h78+j`Hr(D;qcOO`yA@ayzo0xPJLhpkVWyBx#$?+YMqyzs=MN zdEeA3e)DZ+cuL#x`B#+6-{(`W3vCGV+*Ts6_#PPgKW+1>`nu3+fpPFTdHE+k`Q6`I zxsUp=4M`5F-M&PGyi0%p;vtb!bkn|4%ms)DmQAwT~NsE=G)8T#SLnU}-{ zX>zl3j5qKT`UfFBD#Kn~s;3IZXRGEOWl_ap#FR;F{^_p~WAa7t6W8K`gG(1yR_ZmE zV@S7X$riltx9e%?A;~;2#I}&u)=q3+_|=z~^>NQ8Ej4wpfDzZk#Dw1T`%dj4O9>c1NEKi>V{q~CuDMd9(1@y)3zt(Mdtsk!ko zV?XcLkC~XdyShY43|loSk}PsD=@PQqrOI`z?4weenwkb^Y%CVFFt!+^%7Kz#&bCkA zLf1wKc=Y6noVfUxdw}w7?d?<)7H`K(4Sm9L6|*1VK7Bje=4bUv9;#3INT z1aeABcH2`mCHn1;C@gwMM~@~d%zUxPlQ_*DrVUhvknoh+ttoINOy{c>`}z6pF0@yj z(#jX!Q$oL8|6uAkMeZ{OVZ@g&<@OsY($YQJbq;X}3GFQ{6ikJ=W@AMOTo#Xf3e-wO zD6lCgEEw6?cH!#$u18ZZ~KM749$L+6~ znTASAN-MpIP*|N-`%_u8Yblua?(yRXFg<;0bGSb8>C-1(US7eglZ7<#K!dq$2E(wh zuqUR&^rWPTb&fj@J2Rw+?;i9e3vxew`pAdR)GK3G8UQJ>7Joa zrZ^!1!O7_<>L(E_EUd-V!X04wH8nL34i1u}8spx`SilM9R#sHi)oXyox3q}GNt1&2 zQ=b?et@7s08&G*+l%h|cKHc2fiec65oqLZR7rn5wG*YbF1o7$X>q|vcXbubP6SmY9 zLxo$|Jw46;_;If1^%cDR==eCBOav(anIHev3kvLuzs*=TBe1Etd-pCSC1oV7w1}EU z2jl>)bbLl}vFGvjbZ_EwDvH8|wqRly<#e5+=fw#d@0!N=tmk!NeEj;_T5e`0&ADb^ z5_&nmkYa95&dJ%?Os&1yRJAq4QzV^i5}W?rEF%-jWY4S9FOL<`(9lK;HGNIW<5N;7 z)*lh%if$x4vra26F4oGp=T(X9B-(t+O>9T=`TXT>}_``$M;Ha>UUE+{Agz=o5oC3`% z7G~z)A8+{F&+IzCJl1vF71z-rd$j*y)$UAH*S&RNSEWeH-=zHZI;y->G?Vap|0XO9 zOc|u$1VnZqKC8#|<#9D9ngy1LtJmF6zP{%tC%AZc_S;jZE6JV?*4DzJOTP3B3`9@f z2L4>x)+uA-$4QuP`Fhx<=rKTecB;VpM=VQB;WRe9) zb4A(D;4;k2%(98RBd|`2*)pF#e`Zkl2Waou@67P?@}3npT|MRCs0R%a6{RdBgpl0# z!eAyeKR-V>xTnGO*yCdNg;;tGV5*Og&zrY_MlEyiza}OccgM=BsXfzg3o0)!_kTe5 zh>7Au61qtqIB4YL$$+Sxov);%x*#X@kA5CU#>SrPwv)qv<{-`+Tnv1nEQ+1$K1+0_RiCVlR}r1nin-t(~w%gyME^xH!qPU~%!0s!DOG$tTU zRSPwgH8cV`>KUIue+~dVI5?=BuR=-G^N}bADulnBTQyXrU_$m~s9i9687V2nKf{P& z{6ZTg5NI!7o^IFeC@Cv@o-W0FKtfiiQvC6JyxzGiB&2t)*_TzP-p|A>MYGCMyZXJb ztgKb9jyXdqmMvZVFP#(R5vVg7nLJ4FoQMU<7JYE7<=CarYqoj!QgC&LoO zL`AX1z0SA?^;anAV0B=jPw+a(-+yho?igg#n%xWf3wHxb5qVEoj zYU`bu+4b2*aSV$#5nz?6XD;IV?9S9Hm4dw+f)iDeZ*$pfPbGS9%s=n;`sh2zc)q!fS^F)=X}QE*N{!BnNi zWPhsY(BL4TUqobN)79CA!`Ayn}226 z0)a$iXVWG3wHshczhj%Ov86K_cFPC08A8TC(cTW_-pSQf7qZp#1A@utK+!2q)WXlr za|JpLynK9G^tks3pBg~DCrDRg6v)cTa&~t1yx46&SR29#`}~#H-gGeIme*|m@=*XT+z#NelL>#fdN!ZOle8UC*0gkz>i_I+}zwC*02d* zy20=yP);Gny1KgH!g9w_X>+NRkfH#bm#|P$uUAEczWfEp^TQ2aFNA;U&MTMPt+~1Iq$C07y+tT{ z(4LTqeM0&Cx#q9tz02d-jrH{_C~{yp=THwmqN3s<=>;=BoEHH!mZ9;RH}B_L`~iER z7qAZ;ix3j`&6|{`SZkXT6&uI1p0YiEyvASAJdt|0BGTPIWSZ-2azB3j(9zL>Vacuo!?3qXqK=;+Sz%bh=eo;Yq#&CkCjCnp~c0D95h)&@je zpJ=e7LlOuNRLMs7^DC&kkOC5i|6Zoc7=Rc8>xIUPJt?k)LUZfil@^-5 z_ehVnCS$&Q$yii=^z2!6Y3X2VtGK47<~wgWIk_jmOH@@=@A)pw&qn~O{qx7G`3?-z zsX0a1n?<*g1Q$1>u&}{#N5|H7zu6ayQTa9o2F7TyF33=RKE5x`?+-!3!P>mzm;gSL zSuT9CQQXAD%p6MkJPUaA=I;_eKNM0@QWBb*MR3O}*RI23RGET_g0wn4J+)o#jsq5~ zz%TJ`sWS@XOC6B^?diIxN}|olsvV&6ASk#wIhB7V>uPAMFE4XJtgQ4U%O>&hFf&`M z59dC61`SlNswW&AeDEt^$iU=|fB*gsW!G}HQTwGOr>deNItB){Pl0yb5nQlS9r2^L zckXWftCLAq-9~dmLp(CF8K``K#SiFY>_DWo--frvU+*@=my zfvpOk$#0@kHl8ClCFKn~@hi1$2vNWXT3T8_dbvFE-j)5a0`-MIfBphx#G#VN$jV~T zsV8VhgscATwX?SexFIGdpKbEIUR*Q)nP$H+`ncKdOVh<(V0`=puqKy{QD#F%yLEqj zGBUEQiN^!G>#&O8QBIZ`1_lIlF83rnxPM>ZY?X=xQOMy<5_*`_($dnb`|)(B;1xYK z%GvP_IT2Ckn;JJS}A~Z2?M#_01^{8$m7S4B}ox1un>BROG}^9(|7ds6$8HmnhG6@ z_jYzl!{p#M&+*tU6A%#@85x;E(RZ7(shNU1f{+KYuc@X+uiVPQz%V#6(t0nX63Pxj z`D;weF-r&?VCqTFtmh%|Zqf=O0R$NRVrU}2Yo)h$GYl|?ryO?%#`oL&2y)~Lvwe!? z?vEKv5CSHc^78VGszu#>eLKHP7C@x|56E*m9xqX2GBY#7!;6BOC&b3mJABYkS1(a3 zh>eYXteBPJ0)z;ly8qE~Z(>4t_}u2E7KG^B9NXI#lvd(Yq3od;X3e&R-TL}^+&T_c zt?KV@OU{nAp1&KU7QYO36ChViL4?q)9Lto7fd(<4PF!p(H7%`%rsnzS>8=x>_*pP4 zsWu?&4LSM!TkQ_ZD^rP{;>NRA^2nx82}b}mJ38p;=#X7mAZtxr0sEz;q%<2`D&*)v zh*BlW$Vy7e#p-6~=huNg6Y+y6POGd2z=iurNJ(vUu{J4n(E36E-uMcJhCrLvH#YQ> z?*K$S6^2&WWTnNgU%#%8J+B=hY6sF^u&S4e=_MTisTYCBh2^`1s*I-YR_Cylmy`3} z#zvt&0eNU>2!tV*#l&!tb{*uTfPg^Kx(jj`Yqi;ZTrF{N@i%Y&K>0ccjzz}rvN=^_ z3#tw6Zym@U-nT7&_ego+R5obO03`zm?9ZP+L#1ST*aZ?m&m%^T9s><+@9>bBm9^^I zw`cGFd28=Lv}&ww-$^Vk@;xYS-;eTB&||NZR)b}0xk;8(5m z=~6K))Ai0n9UXqZ3>d!mffrn6)Dti0>0xbM5*exZZ!tbXpzu66I5^sw^<3^I)Yo5t zXvP3q1AwvTx->U8r)O!I6dzA?|9%?59Wl&dtx9usuu7ILtG)`jy8xFO7#M&82uCP7 zs(ItQLM5pyx4tN;tH&iL>q1!-7ylF&rw$q(wEEJn?^k|T2T&ql7}U5P7bPVnY2m+; zk&yxOP+3VSNx+?9tm#wN($x+u!r_=MDrK_!(RdQStIN@*f1=D?xHQ1P&Q18WlY7=X zY{gep*c%yTL7tWxcEOA-ZTq_I{h}MnmIVtByn^s!kyE(Nom)R3Dwg;$&w#pkULLA4 zGZzB3T>@437g++b($eO$lReHrd;#qOic|qf1>C%IOtz!9xANP!Kf6v4&7kdVG_d)| z`XF}%1+M{2_XL>j!7_Dnss`ig`s%VIFm(%Y9Leja%iljA>gvwHMnt8AI*WeqUUac9 zz1#7$BQclx$;m<}t9C7QHquK#=atP(KAXi3knCw9w@Q%-eUs1;!b1V0LDYnYhr|3r z7q0BvHxx|FsPw6c$;mn(kg7#m<4vATUmqgcH!TL>G0G9&us;f8aEl;p)6&vVD5+kA zU|?Z!gUJ@iEa(XU-)gO>iYY|4jft8K7i-VXW&R7 zU^|1eSZlulHwATbw&aSS=p287e`6wt9J_P^@_4Qgu z&BWj-#KdsBoxC5HB?Y+SGUx~ceg^dm$aAuQJ5eqYV6#^nRE?I_)~)HfD#&R_H4qiR z+oOB3>Ocr%kqcaaGXQ8CdHG%8SPHn8Ry4N)1vbEeUW*?N2qsc4^WQxQ+)ZcejGMSA z-C&3PwFDPHwLs1KFx;^KzW;9ZUtwZEO8#G7f|fs$_rK?3gpMh&tY4GXKPtSmCIhmnbi1S~9h`2irK%e|;d`x4=S2My40UNZ(f;(e#1Fv-Nz?Dep{8(hjGQDUPQJn@2f1_&4!kiV2?>wP-{`c>DZS85G=A&*32ivTUx4eAo}P|MiE)>TgQFbef<-ky zcodxM?2L?zbuNc_g@uJoKC6I;fRPXpu6v89_PfAQZnzeG1&n~4$7T^!7*u5Nsv#nv zB3d}jzJ=t0v}43Y1GAllN)=tHxfx{D1m8_Kz$>R5$O%Fi9(WUg=i?P-s)mL%i88Z5 z)gdS+4Wyj-W({RVJms|!XM(X2G=4*jTwY#|jEtaC zf?snAdu%2R*afaHY=13~J8Vsi0EU7kQ()id0@B7O4C(}fSL$>3~<6OAUE?Xx}p676>Lm|I3+W41Y`+RPXGtG z;u}8!d~7@q*TNU?fi7VZb9MLjZo~XA4=;Ul6HUS?$EP=R_G@M}0i zU7%*hPEjHuCEakhfksmnv>N8Up(GMU!A$74VVEM5Ja;fxQHfqG1j|WGRFnz^>D(0F zzHsOEZ97oa+c*@Il&N?grJp{DH?)%@A$fzR!*xCe#F#fx24x)R?O`V@B?cxYDkT7} z!23T445<+bz>TISo_SumiQVPLM=0Gs* zRy}kw*IT$u`YAx>{`z1JZO@()aUiYW-5nZX0bMNoD|L8qVCrrKr6!@l|5HR*n9!#$ zBACC1`ukBZFu?B;D@00_gL--c)!LNj2W#HzyE0(73MD|DfNU9S4a5iU5CFM#?me{? z@Mp*=&N2Ml{QMVUV$j|L#@o1ki3oQx3s&5MWB_wa)SiW-I3gkfwueaywk?C71cL*k zAgo*nq2GkV*w}bY^&yj**ZQs{Tgy-y!o(E#ao`jw9Dnfyh+gJzh5qn!UfxEhU6H+j z%F0T+(ZT!B6QLGe$T9MQrq+w67a7jXTGdY;Jxbf&fWZ-ivu;Wx3EOF!belXP1j|ZG zA&0P|vqS-Zh+QEe8zRMF`Chzehvzc7V7cpU4FVe6MJe3^L63{-5h5}$%|porXGC&E z;ZGZKktkut#rI;F5Jdc7A5udD|Dlzut82zRNS4KJ=r5A1&;||) zdCmtL*?uuHPdiioBAckP@?7i)hZ0{-(?}yI*c9&jUJ!hb)&RU5b2-NBf$u`mqamQlVXvbh zIH_xE&OnL5=p5z_li)u=40U!AWI#a_B34jU1?*y_%&4w@#=~=}Q2~wTm(jZi2jvwN zQQMX#4?!FOkV4!P_8$qQ4}hivK{+X+TLNzcLqhV3gu?_|)Esu94M)--t`ClmLZPBP z#1dQ32Imn${b2FegO{{7GYOaj%B8%8?PjMc;FX}=s$AD)Gz<&YV;TzKr_t-bbE1It zqN1XAz1cJhpeE*s^y7k~Lgy1i$bL69tZP$PR21oDkeezrRO{ADH=D zWvk5CG!@~E$|{Q}sluL$+PyzHjH!>Pmf9C~zn%YLH9eC2&f>Kmz>D!pA=rA18urEm z_4!|S$p6=Vh?{Nx|FuIub06kD8B}{|>HSnV-|Ef7rP%_i48)`XsK&*`#m!A~OUtX? zeEMG)m_aPjE)R3>QVz5CIM>T4@;8S>;(weA943aZD=Rr{^!xIgD{ zZTwM}v+p2y(TetoqZ?t}2L%DH%*}=a5KpgPV;YGSeO;%A3Pk8sLu}B~uYx;e9|LX3 z{Hm%d@B|O`piqF>c$4tcOE=pJ47;L7CSt*jp{An)Ul#TS)FyPU?1%aThX!ug2NwUt zMD63I65cUv$p#gVh1Jw-a<+n#7*Q;mCW^KhV&fS;r@{B7WfrWMrv5kE_LB*C`^sr* zUi$X(Rh8{r?j&7uR~w4i{`pB`S?SKP?jbL~d>ZSyvwLv&d0+X+_g+{lOq@;x!0O}k zH=qv#UU{R2`TD*nT)OGSO483SESNE0x8mz^l)nc?V8-0NvR_e=FoBqomv_@FaoJC* zeWq-IWxh5hLNK_YPraPgkBSIR!x(Dd&Ae7)!w z-6xBI)6^Q&wOpxzdh3>-2~{3NC}PY`B{FOf0kGSL@>R||JUndF`&HfS6zmx>adGQs zUu(NIHa4C=dp4}$0U`iM+yk^v_A%K*YZVFBN=i_rp0cxpcL}}_h-cj$E?|MRwY9uF zJZl>po*h=IV>p*%kEto${2M+CQDA>NXmL~-rKVqZ@hK{LbmmK+t#_B2qxJ zS?fB+q4EJcX4EAgDiJt!w)XZFB_;1F$z_kPo&6i`M%ADYsTcWRpP{L`=6yS~5O8=z z?far3Q(97HGl^4RTN8zf;ylQaeCh={0s>kxe+TU^?MN_|0gj4_ilD!s97E)BI_E?p zvRM#6_4>_}`XEc{pd+<6Ns%+-Ogq2MfoGy6Z_YggAiSxVWxk*kYHDj))V`$vtTG=k z2YqN8)W+krYV2C!wC9&W94MOZ4Y9XREE!n6IAfz1KtM1dkub0H17B(#a0dw9yPDw- zo!F+A`)+$f>rLdO(+^~4uI&6_VRp_Y4ph>b&eE|5T)I z`>1eLTM)0tT6*>L97>i@>Xcxu!ia;a{C*bJu^Q98rgy%h4^%iNj);|Kbt639CWT@} zUb*6+-6i_jGr3h|xKM*!c-e4)?^|WAPVRbKh;AgooH~#-lVZuXvT`|J)p}`@s(^>% z!h(QHel5;GX%yenFhSePUHsFq3`JhG@XVtJG5OQU{y|&cu=o!lQKeR@HV!4jV^`f6 z|A*NO6_q2&kNw?58Nu#Lp}$?T5}I7&wsuEP`;7UOrgo1(@9X@7<>VCQFo&~_s5+X5 zuZ_>SXB#|s#gt+hDHjKKXUi3br3q}!&krh}aPRz;=N6HyF}V~!3bM?;8XFX7Rs&r~RvedVO zhv@of1YO!_SoFP<3y!4ll zj!RK4zb_#d;ACy7g%GJxfDUqhmQdY>SA>lT*~G z<6HAshO;8e<&}tpSGCc5v5`o5Wzsc7fAs;K5p)&im&c(GSYv&)|nmmv{U z2(-NJSQ$HSP$tjK9rho5j?L~P?vBGxA%Br5tiYI~BPtm%udHu?<#k`_0-;odz=!N- zuE2@7+{#(lZ5GZ7K2^KT$fR33rF@Jn{_!bMooG4lrsC2>DWmwm`G{DSvx?TgHWy{D zv(3+nU6GW*h$v1U?fV;CbY>~@xyi&(N8L?H)v%8&r~WBDi{=IQa|x}UDKWaeJhayY zSE%YdB$ggtt61C*m7A&w8`GvlzMQLM-#S>uu)cgtSL-i;_wykd`=guyQYm+O#>+gz$_NbPd2G!9beu+4^T^P)) z+IPE<nWbMKu;c!rWwv{-E9p$;QBMn2A8NK5xN#>koAxz(8PPJM%_;`ycW zNbWfD#;mKGzUt(9;EcjR)uq5_GWMw8^-Q}v76Qd=yx~U`d(!(m3-n1zQ6~1A2Z#y! zQ_`l2<%f0tm0^FZvxQ?o;u+V-q0Oa(4~-Ona^M=V`)sf?U~G%EdCI zrYj{relnNcb9Xt-#z@(1vz}3U{O*4-}w# z7opz)sQM~m)Vw(JmJ2Q=DJ4omPv7oKPx!Qu1bb4So_Fpx`}89d%!lW8$|Qt2_4SwU z7jqwWaEWBU)k%rO&}F1FmXZ0J&!sdZ)Mk=nODh0$<#;nZiLjJ z#<-ibZ9;6?Wh_|9;tj#U+$<~lOwOP|w1i?|(Bqs}!G4#Yd zAr#c~Nfv)z_ba>M$^nNYpD^#41sYE8yjK&SFW1uURQZy@{x_3VCDMc^B9gDC;=0q$ z5=g|zhhsNMg;A`t*{j481w0?wiCz+3n}e={-fDgS;=V zE$^Em9cqq>3RFE+LHAu~?Ik<}>8)Xe3#L z+4^HHd*&`Iyj76pa3@B)s;8(mX>tai@t40=pdB%`);5QBCNwN{zPYWr7s*ZJS26jS$|_l> zzkbrNw;T9N>mp4*!qCX@k|B#;L$=0RQRI88^c|rVwN<=@RtGqTOack~r+ie>3 z9ybJqfqsc~gaGASPL410EeywkrScBiG&n%_h*O8=%DW_4h}0 zkC(BXe~hqO`-CBquSG-JaT#J+~@ou3Gs?2cf%Fu zt&m@xw4lhUrH(_v_wPu0A9Cj_=6-`p+~a>+2K^G)vtmPZN-6qzs@6U~HCaNs;^!OQ z8fEa8IsaM?ayKrIBSEGsXDP%8%suzylrmfWe2=70HBu{#(k>X9G;WAe(V0Y zvSRC#3-%mg`LDRUCiU3&K7ZH4piIN`W4OhF+vzyz4PE*i`E)g=hth3uD@JHUrS`y{ z@V{VAgAbCQeH^`V+A7FYvRn=m*Kdi)d_}cDEGgZdoOgNIyHaXu=LEhGHgZ1^*l%!%^RsN-@ zn(qTa>$Ck??Ddjw7$Ph{JzpoG69qd!PlRyliKpjv?YJoPupzRww6&oJG}UtGL-MS= zrPS!#;1RKWy2Wyh#1lo;ockiQzhCj6DT#Y09!$>nO)X>!>h7mRoze2%pLO3om7~gy zI*70NsX0Y?@KLlwsrJJnran6@Pw3?x1O${et@1Y-iVx-;wrFL(I>FXHuq$9Q8YjO! z_AKYGs4K-WmuRfWZ!&av{>C2$!fHTlKfyrHYSN7i)>z4 ze9d_fJt~7n`^#&*{pAI99(t93bo>0ccW&+)*e}pXae>_)(8z@kXE$uP3|Gv2_0FWH z;rbkQ)v(^%9btdUL`nU}*vhp`pceN2QD1*0-(WTy8(x=@>~1`XH<-J6S(_-jDoqXF zq0mnd^xGWg=;#RAZ-fGFC&)#{swS~Bembz_Rz*?Kb@xvjOENQ)$gPJVBNSL>%;VE7 zKG-%-=6!wyeaQR7?P+a`LFKcW!>w^*#E4bqiyKJ=#!bpC>VH6TV^i z^kV-EAx|QhXoP7)?~V)a-O-C*@Am>6_T%5>OLda3W2Y5qD7w{h$xvEmPrJt=yNP)| zy~Ju`3N$3kO8QvD+-O}&W@PI~+c;WkYmYB0^#W!3ar|Lf1sCPgFh=0ui0De8A{P>Z zSHyQFb+pWkoD?4^jtcvPr-DH~DH-%lK2l1Yb!$y42a@!{o45S9(gJcAl)n!grO1`r zA6Cq|YdplWnAVx7^@A^K;K13A2aWnaSu$|;->)I~Zy#t0Gkm&R@RBGP8ciWT!*mDP zBaj>rl~jlI Date: Thu, 28 Nov 2019 17:11:41 +0100 Subject: [PATCH 12/20] Expose `Seed` class in the top level iota package --- iota/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/iota/__init__.py b/iota/__init__.py index 6a482874..8ab602bb 100644 --- a/iota/__init__.py +++ b/iota/__init__.py @@ -38,6 +38,9 @@ from .api import * from .trits import * +# Expose Seed on the top level package +from .crypto.types import Seed + # :see: http://stackoverflow.com/a/2073599/ from pkg_resources import require __version__ = require('PyOTA')[0].version From fb73a86ab2eb36c8246574a5b54058d3c5042bd3 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Thu, 28 Nov 2019 17:37:41 +0100 Subject: [PATCH 13/20] docs: re-write PyOTA types page - complete rewerite of types.rst - Add/extend/polish docstrings for several type classes - small correction in basic_concepts.rst --- docs/basic_concepts.rst | 4 +- docs/types.rst | 521 +++++++++++++++++++---------------- iota/crypto/addresses.py | 20 ++ iota/crypto/types.py | 92 ++++++- iota/transaction/base.py | 293 ++++++++++++++++++-- iota/transaction/creation.py | 167 ++++++++++- iota/transaction/types.py | 17 +- iota/types.py | 385 ++++++++++++++++++++++++-- 8 files changed, 1184 insertions(+), 315 deletions(-) diff --git a/docs/basic_concepts.rst b/docs/basic_concepts.rst index b2fca725..fed8885f 100644 --- a/docs/basic_concepts.rst +++ b/docs/basic_concepts.rst @@ -140,8 +140,8 @@ field that is a `unique identifier for the bundle`_. :alt: Bundle structure with four transactions. Structure of a bundle with four transactions. Numbers in brackets denote - (``currentIndex``, ``lastIndex``) fields. Head of the bundle has index 0, - while tail has index 3. + (``currentIndex``, ``lastIndex``) fields. Head of the bundle has index 3, + while tail has index 0. Read more about `how bundles are structured`_. diff --git a/docs/types.rst b/docs/types.rst index a456f553..b1780b11 100644 --- a/docs/types.rst +++ b/docs/types.rst @@ -4,74 +4,75 @@ PyOTA Types PyOTA defines a few types that will make it easy for you to model objects like Transactions and Bundles in your own code. +Since everything in IOTA is represented as a sequence of ``trits`` and ``trytes``, +let us take a look on how you can work with them in PyOTA. + TryteString ----------- +.. py:currentmodule:: iota + +.. autoclass:: TryteString + +Example usage: .. code:: python from iota import TryteString + # Create a TryteString object from bytes. trytes_1 = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA') - trytes_2 = TryteString(b'LH9GYEMHCF9GWHZFEELHVFOEOHNEEEWHZFUD') + # Ensure the created object is 81 trytes long by padding it with zeros. + # The value zero is represented with character '9' in trytes. + trytes_1 = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA', pad=81) + + # Create a TryteString object from text type. + # Note that this will throw error if text contains unsupported characters. + trytes_2 = TryteString('LH9GYEMHCF9GWHZFEELHVFOEOHNEEEWHZFUD') + + # Comparison and concatenation: if trytes_1 != trytes_2: trytes_combined = trytes_1 + trytes_2 + # As dictionary keys: index = { trytes_1: 42, trytes_2: 86, } -A ``TryteString`` is an ASCII representation of a sequence of trytes. In -many respects, it is similar to a Python ``bytes`` object (which is an -ASCII representation of a sequence of bytes). - -In fact, the two objects behave very similarly; they support -concatenation, comparison, can be used as dict keys, etc. - -However, unlike ``bytes``, a ``TryteString`` can only contain uppercase -letters and the number 9 (as a regular expression: ``^[A-Z9]*$``). - As you go through the API documentation, you will see many references to -``TryteString`` and its subclasses: +:py:class:`TryteString` and its subclasses: -- ``Fragment``: A signature or message fragment inside a transaction. +- :py:class:`Fragment`: A signature or message fragment inside a transaction. Fragments are always 2187 trytes long. -- ``Hash``: An object identifier. Hashes are always 81 trytes long. +- :py:class:`Hash`: An object identifier. Hashes are always 81 trytes long. There are many different types of hashes: -- ``Address``: Identifies an address on the Tangle. -- ``BundleHash``: Identifies a bundle on the Tangle. -- ``TransactionHash``: Identifies a transaction on the Tangle. -- ``Seed``: A TryteString that is used for crypto functions such as +- :py:class:`Address`: Identifies an address on the Tangle. +- :py:class:`BundleHash`: Identifies a bundle on the Tangle. +- :py:class:`TransactionHash`: Identifies a transaction on the Tangle. +- :py:class:`Seed`: A TryteString that is used for crypto functions such as generating addresses, signing inputs, etc. Seeds can be any length, but 81 trytes offers the best security. -- ``Tag``: A tag used to classify a transaction. Tags are always 27 +- :py:class:`Tag`: A tag used to classify a transaction. Tags are always 27 trytes long. -- ``TransactionTrytes``: A TryteString representation of a transaction +- :py:class:`TransactionTrytes`: A TryteString representation of a transaction on the Tangle. ``TransactionTrytes`` are always 2673 trytes long. +Let's explore the capabilities of the :py:class:`TryteString` base class. + Encoding ~~~~~~~~ -.. code:: python +You may use classmethods to create a :py:class:`TryteString` from ``bytes``, +``unicode string`` or from a list of ``trits``. - from iota import TryteString +**from_bytes** +^^^^^^^^^^^^^^ +.. automethod:: TryteString.from_bytes - message_trytes = TryteString.from_unicode('Hello, IOTA!') - -To encode character data into trytes, use the -``TryteString.from_unicode`` method. - -You can also convert a tryte sequence into characters using -``TryteString.decode``. Note that not every tryte sequence can be -converted; garbage in, garbage out! - -.. code:: python - - from iota import TryteString - - trytes = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA') - message = trytes.decode() +**from_unicode** +^^^^^^^^^^^^^^^^ +.. automethod:: TryteString.from_unicode .. note:: @@ -83,205 +84,269 @@ converted; garbage in, garbage out! use ASCII characters when generating ``TryteString`` objects from character strings. +**from_trits** +^^^^^^^^^^^^^^ +.. automethod:: TryteString.from_trits + +**from_trytes** +^^^^^^^^^^^^^^^ +.. automethod:: TryteString.from_trytes + +Additionally, you can encode a :py:class:`TryteString` into a lower-level +primitive (usually bytes). This might be useful when the :py:class:`TryteString` +contains ASCII encoded characters but you need it as ``bytes``. See the example +below: + +**encode** +^^^^^^^^^^ +.. automethod:: TryteString.encode + + +Decoding +~~~~~~~~ + +You can also convert a tryte sequence into characters using +:py:meth:`TryteString.decode`. Note that not every tryte sequence can be +converted; garbage in, garbage out! + +**decode** +^^^^^^^^^^ +.. automethod:: TryteString.decode + +**as_json_compatible** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: TryteString.as_json_compatible + +**as_integers** +^^^^^^^^^^^^^^^ +.. automethod:: TryteString.as_integers + +**as_trytes** +^^^^^^^^^^^^^ +.. automethod:: TryteString.as_trytes + +**as_trits** +^^^^^^^^^^^^ +.. automethod:: TryteString.as_trits + +Seed +---- +.. autoclass:: Seed + +**random** +~~~~~~~~~~ +.. automethod:: Seed.random + +Address +------- +.. autoclass:: Address + :members: address, balance, key_index, security_level + +**as_json_compatible** +~~~~~~~~~~~~~~~~~~~~~~ +.. automethod:: Address.as_json_compatible + +**is_checksum_valid** +~~~~~~~~~~~~~~~~~~~~~ +.. automethod:: Address.is_checksum_valid + +**with_valid_checksum** +~~~~~~~~~~~~~~~~~~~~~~~ +.. automethod:: Address.with_valid_checksum + +**add_checksum** +~~~~~~~~~~~~~~~~ +.. automethod:: Address.add_checksum + +**remove_checksum** +~~~~~~~~~~~~~~~~~~~ +.. automethod:: Address.remove_checksum + +AddressChecksum +--------------- +.. autoclass:: AddressChecksum + :members: + +Hash +---- +.. autoclass:: Hash + :members: + +TransactionHash +--------------- +.. autoclass:: TransactionHash + :members: + +BundleHash +---------- +.. autoclass:: BundleHash + :members: + +TransactionTrytes +----------------- +.. autoclass:: TransactionTrytes + :members: + +Fragment +-------- +.. autoclass:: Fragment + :members: + +Nonce +----- +.. autoclass:: Nonce + :members: + +Tag +--- +.. autoclass:: Tag + :members: + Transaction Types ----------------- PyOTA defines two different types used to represent transactions: + - :py:class:`Transaction` for transactions that have already been + attached to the Tangle. Generally, you will never need to create + :py:class:`Transaction` objects; the API will build them for you, + as the result of various API methods. + - :py:class:`ProposedTransaction` for transactions that have been created + locally and have not been broadcast yet. + Transaction ~~~~~~~~~~~ - -.. code:: python - - from iota import Address, ProposedTransaction, Tag, Transaction - - txn_1 =\ - Transaction.from_tryte_string( - b'GYPRVHBEZOOFXSHQBLCYW9ICTCISLHDBNMMVYD9JJHQMPQCTIQAQTJNNNJ9IDXLRCC' - b'OYOXYPCLR9PBEY9ORZIEPPDNTI9CQWYZUOTAVBXPSBOFEQAPFLWXSWUIUSJMSJIIIZ' - b'WIKIRH9GCOEVZFKNXEVCUCIIWZQCQEUVRZOCMEL9AMGXJNMLJCIA9UWGRPPHCEOPTS' - b'VPKPPPCMQXYBHMSODTWUOABPKWFFFQJHCBVYXLHEWPD9YUDFTGNCYAKQKVEZYRBQRB' - b'XIAUX9SVEDUKGMTWQIYXRGSWYRK9SRONVGTW9YGHSZRIXWGPCCUCDRMAXBPDFVHSRY' - b'WHGB9DQSQFQKSNICGPIPTRZINYRXQAFSWSEWIFRMSBMGTNYPRWFSOIIWWT9IDSELM9' - b'JUOOWFNCCSHUSMGNROBFJX9JQ9XT9PKEGQYQAWAFPRVRRVQPUQBHLSNTEFCDKBWRCD' - b'X9EYOBB9KPMTLNNQLADBDLZPRVBCKVCYQEOLARJYAGTBFR9QLPKZBOYWZQOVKCVYRG' - b'YI9ZEFIQRKYXLJBZJDBJDJVQZCGYQMROVHNDBLGNLQODPUXFNTADDVYNZJUVPGB9LV' - b'PJIYLAPBOEHPMRWUIAJXVQOEM9ROEYUOTNLXVVQEYRQWDTQGDLEYFIYNDPRAIXOZEB' - b'CS9P99AZTQQLKEILEVXMSHBIDHLXKUOMMNFKPYHONKEYDCHMUNTTNRYVMMEYHPGASP' - b'ZXASKRUPWQSHDMU9VPS99ZZ9SJJYFUJFFMFORBYDILBXCAVJDPDFHTTTIYOVGLRDYR' - b'TKHXJORJVYRPTDH9ZCPZ9ZADXZFRSFPIQKWLBRNTWJHXTOAUOL9FVGTUMMPYGYICJD' - b'XMOESEVDJWLMCVTJLPIEKBE9JTHDQWV9MRMEWFLPWGJFLUXI9BXPSVWCMUWLZSEWHB' - b'DZKXOLYNOZAPOYLQVZAQMOHGTTQEUAOVKVRRGAHNGPUEKHFVPVCOYSJAWHZU9DRROH' - b'BETBAFTATVAUGOEGCAYUXACLSSHHVYDHMDGJP9AUCLWLNTFEVGQGHQXSKEMVOVSKQE' - b'EWHWZUDTYOBGCURRZSJZLFVQQAAYQO9TRLFFN9HTDQXBSPPJYXMNGLLBHOMNVXNOWE' - b'IDMJVCLLDFHBDONQJCJVLBLCSMDOUQCKKCQJMGTSTHBXPXAMLMSXRIPUBMBAWBFNLH' - b'LUJTRJLDERLZFUBUSMF999XNHLEEXEENQJNOFFPNPQ9PQICHSATPLZVMVIWLRTKYPI' - b'XNFGYWOJSQDAXGFHKZPFLPXQEHCYEAGTIWIJEZTAVLNUMAFWGGLXMBNUQTOFCNLJTC' - b'DMWVVZGVBSEBCPFSM99FLOIDTCLUGPSEDLOKZUAEVBLWNMODGZBWOVQT9DPFOTSKRA' - b'BQAVOQ9RXWBMAKFYNDCZOJGTCIDMQSQQSODKDXTPFLNOKSIZEOY9HFUTLQRXQMEPGO' - b'XQGLLPNSXAUCYPGZMNWMQWSWCKAQYKXJTWINSGPPZG9HLDLEAWUWEVCTVRCBDFOXKU' - b'ROXH9HXXAXVPEJFRSLOGRVGYZASTEBAQNXJJROCYRTDPYFUIQJVDHAKEG9YACV9HCP' - b'JUEUKOYFNWDXCCJBIFQKYOXGRDHVTHEQUMHO999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999RKWEEVD99A99999999A99999999NFDPEEZCWVYLKZGSLCQNOFUSENI' - b'XRHWWTZFBXMPSQHEDFWZULBZFEOMNLRNIDQKDNNIELAOXOVMYEI9PGTKORV9IKTJZQ' - b'UBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSKUCUEMD9M9SQJ999' - b'999TKORV9IKTJZQUBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSK' - b'UCUEMD9M9SQJ999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999' - ) - -``Transaction`` is a transaction that has been loaded from the Tangle. - -Generally, you will never need to create ``Transaction`` objects; the -API will build them for you, as the result of various API methods. - -Each ``Transaction`` has the following attributes: - -- ``address: Address``: The address associated with this transaction. - Depending on the transaction's ``value``, this address may be a - sender or a recipient. -- ``attachment_timestamp: int``: Estimated epoch time of the attachment to the tangle. -- ``attachment_time_lower_bound: int``: The lowest possible epoch time of the attachment to the tangle. -- ``attachment_time_upper_bound: int``: The highest possible epoch time of the attachment to the tangle. -- ``branch_transaction_hash: TransactionHash``: An unrelated - transaction that this transaction "approves". Refer to the Basic - Concepts section for more information. -- ``bundle_hash: BundleHash``: The bundle hash, used to identify - transactions that are part of the same bundle. This value is - generated by taking a hash of the metadata from all transactions in - the bundle. -- ``current_index: int``: The transaction's position in the bundle. -- If the ``current_index`` value is 0, then this is the "tail - transaction". -- If it is equal to ``last_index``, then this is the "head - transaction". -- ``hash: TransactionHash``: The transaction hash, used to uniquely - identify the transaction on the Tangle. This value is generated by - taking a hash of the raw transaction trits. -- ``is_confirmed: Optional[bool]``: Whether this transaction has been - "confirmed". Refer to the Basic Concepts section for more - information. -- ``last_index: int``: The index of the final transaction in the - bundle. This value is attached to every transaction to make it easier - to traverse and verify bundles. -- ``legacy_tag: Tag``: A short message attached to the transaction. Deprecated, use ``tag`` instead. -- ``nonce: Hash``: This is the product of the PoW process. -- ``signature_message_fragment: Fragment``: Additional data attached to - the transaction: -- If ``value < 0``, this value contains a fragment of the cryptographic - signature authorizing the spending of the IOTAs. -- If ``value > 0``, this value is an (optional) string message attached - to the transaction. -- If ``value = 0``, this value could be either a signature or message - fragment, depending on the previous transaction. -- ``tag: Tag``: Used to classify the transaction. Many transactions - have empty tags (``Tag(b'999999999999999999999999999')``). -- ``timestamp: int``: Unix timestamp when the transaction was created. - Note that devices can specify any timestamp when creating - transactions, so this value is not safe to use for security measures - (such as resolving double-spends). -- ``trunk_transaction_hash: TransactionHash``: The transaction hash of - the next transaction in the bundle. If this transaction is the head - transaction, its ``trunk_transaction_hash`` will be pseudo-randomly - selected, similarly to ``branch_transaction_hash``. -- ``value: int``: The number of IOTAs being transferred in this - transaction: -- If this value is negative, then the ``address`` is spending IOTAs. -- If it is positive, then the ``address`` is receiving IOTAs. -- If it is zero, then this transaction is being used to carry metadata - (such as a signature fragment or a message) instead of transferring - IOTAs. +Each :py:class:`Transaction` object has several instance attributes that you +may manipulate and properties you can use to extract their values as trytes. +See the class documentation below: + +.. autoclass:: Transaction + :members: hash, bundle_hash, address, value, nonce, timestamp, + current_index, last_index, trunk_transaction_hash, branch_transaction_hash, + tag, attachment_timestamp, attachment_timestamp_lower_bound, + attachment_timestamp_upper_bound, signature_message_fragment, is_confirmed, + is_tail, value_as_trytes, timestamp_as_trytes, current_index_as_trytes, + last_index_as_trytes, attachment_timestamp_as_trytes, + attachment_timestamp_lower_bound_as_trytes, + attachment_timestamp_upper_bound_as_trytes, legacy_tag + +**as_json_compatible** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: Transaction.as_json_compatible + +**as_tryte_string** +^^^^^^^^^^^^^^^^^^^ +.. automethod:: Transaction.as_tryte_string + +**from_tryte_string** +^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: Transaction.from_tryte_string + +**get_signature_validation_trytes** +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: Transaction.get_signature_validation_trytes ProposedTransaction ~~~~~~~~~~~~~~~~~~~ -``ProposedTransaction`` is a transaction that was created locally and -hasn't been broadcast yet. +.. autoclass:: ProposedTransaction -.. code:: python +**as_tryte_string** +^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedTransaction.as_tryte_string - txn_2 =\ - ProposedTransaction( - address = - Address( - b'TESTVALUE9DONTUSEINPRODUCTION99999XE9IVG' - b'EFNDOCQCMERGUATCIEGGOHPHGFIAQEZGNHQ9W99CH' - ), - - message = TryteString.from_unicode('thx fur cheezburgers'), - tag = Tag(b'KITTEHS'), - value = 42, - ) - -This type is useful when creating new transactions to broadcast to the -Tangle. Note that creating a ``ProposedTransaction`` requires only a -small subset of the attributes needed to create a ``Transaction`` -object. - -To create a ``ProposedTransaction``, specify the following values: - -- ``address: Address``: The address associated with the transaction. - Note that each transaction references exactly one address; in order - to transfer IOTAs from one address to another, you must create at - least two transactions: One to deduct the IOTAs from the sender's - balance, and one to add the IOTAs to the recipient's balance. -- ``message: Optional[TryteString]``: Optional trytes to attach to the - transaction. This could be any value (character strings, binary data, - or raw trytes), as long as it's converted to a ``TryteString`` first. -- ``tag: Optional[Tag]``: Optional tag to classify this transaction. - Each transaction may have exactly one tag, and the tag is limited to - 27 trytes. -- ``value: int``: The number of IOTAs being transferred in this - transaction. This value can be 0; for example, to send a message - without spending any IOTAs. +**increment_legacy_tag** +^^^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedTransaction.increment_legacy_tag Bundle Types ------------ -As with transactions, PyOTA defines two bundle types. +As with transactions, PyOTA defines two different types to represent bundles: + + - :py:class:`Bundle` for bundles that have already been + broadcast to the Tangle. Generally, you will never need to create + :py:class:`Bundle` objects; the API will build them for you, + as the result of various API methods. + - :py:class:`ProposedBundle` for bundles that have been created + locally and have not been broadcast yet. Bundle ~~~~~~ -.. code:: python +.. autoclass:: Bundle + :members: is_confirmed, hash, tail_transaction, transactions - from iota import Bundle +**as_json_compatible** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: Bundle.as_json_compatible - bundle = Bundle.from_tryte_strings([ - b'GYPRVHBEZOOFXSHQBLCYW9ICTCISLHDBNMMVYD9JJHQMPQCTIQAQTJNNNJ9IDXLRCC...', - b'OYOXYPCLR9PBEY9ORZIEPPDNTI9CQWYZUOTAVBXPSBOFEQAPFLWXSWUIUSJMSJIIIZ...', - # etc. - ]) +**as_tryte_strings** +^^^^^^^^^^^^^^^^^^^^ +.. automethod:: Bundle.as_tryte_strings -``Bundle`` represents a bundle of transactions published on the Tangle. -It is intended to be a read-only object, allowing you to inspect the -transactions and bundle metadata. +**from_tryte_strings** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: Bundle.from_tryte_strings -Each bundle has the following attributes: +**get_messages** +^^^^^^^^^^^^^^^^ +.. automethod:: Bundle.get_messages -- ``hash: BundleHash``: The hash of this bundle. This value is - generated by taking a hash of the metadata from all transactions in - the bundle. -- ``is_confirmed: Optional[bool]``: Whether the transactions in this - bundle have been confirmed. Refer to the Basic Concepts section for - more information. -- ``tail_transaction: Optional[Transaction]``: The bundle's tail - transaction. -- ``transactions: List[Transaction]``: The transactions associated with - this bundle. +**group_transactions** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: Bundle.group_transactions ProposedBundle ~~~~~~~~~~~~~~ +.. note:: + This section contains information about how PyOTA works "under the + hood". + + The :py:meth:`Iota.prepare_transfer` API method encapsulates this + functionality for you; it is not necessary to understand how + :py:class:`ProposedBundle` works in order to use PyOTA. + +.. autoclass:: ProposedBundle + :members: balance, tag + +:py:class:`ProposedBundle` provides a convenient interface for creating new +bundles, listed in the order that they should be invoked: + +**add_transaction** +^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.add_transaction + +**add_inputs** +^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.add_inputs + +**send_unspent_inputs_to** +^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.send_unspent_inputs_to + +**add_signature_or_message** +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.add_signature_or_message + +**finalize** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.finalize + +**sign_inputs** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.sign_inputs + +**sign_input_at** +^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.sign_input_at + +**as_json_compatible** +^^^^^^^^^^^^^^^^^^^^^^ +.. automethod:: ProposedBundle.as_json_compatible + +**Example usage** +^^^^^^^^^^^^^^^^^ .. code:: python @@ -315,41 +380,7 @@ ProposedBundle bundle.finalize() bundle.sign_inputs(KeyGenerator(b'SEED9GOES9HERE')) -.. note:: - - This section contains information about how PyOTA works "under the - hood". - - The ``prepare_transfer`` API method encapsulates this functionality - for you; it is not necessary to understand how ``ProposedBundle`` - works in order to use PyOTA. - - -``ProposedBundle`` provides a convenient interface for creating new -bundles, listed in the order that they should be invoked: - -- ``add_transaction: (ProposedTransaction) -> None``: Adds a - transaction to the bundle. If necessary, it may split the transaction - into multiple (for example, if the transaction's message is too long - to fit into 2187 trytes). -- ``add_inputs: (List[Address]) -> None``: Specifies inputs that can be - used to fund transactions that spend IOTAs. The ``ProposedBundle`` - will use these to create the necessary input transactions. -- You can use the ``get_inputs`` API command to find suitable inputs. -- ``send_unspent_inputs_to: (Address) -> None``: Specifies the address - that will receive unspent IOTAs. The ``ProposedBundle`` will use this - to create the necessary change transaction, if necessary. -- ``add_signature_or_message: (List[Fragment], int) -> None``: - Adds signature or message fragments to transactions in the bundle - starting from ``start_index``. Must be called before the bundle is - finalized. -- ``finalize: () -> None``: Prepares the bundle for PoW. Once this - method is invoked, no new transactions may be added to the bundle. -- ``sign_inputs: (KeyGenerator) -> None``: Generates the necessary - cryptographic signatures to authorize spending the inputs. You do not - need to invoke this method if the bundle does not contain any - transactions that spend IOTAs. - -Once the ``ProposedBundle`` has been finalized (and inputs signed, if -necessary), invoke its ``as_tryte_strings`` method to generate the raw -trytes that should be included in an ``attach_to_tangle`` API request. +Once the :py:class:`ProposedBundle` has been finalized (and inputs signed, if +necessary), invoke its :py:meth:`ProposedBundle.as_tryte_strings` method to +generate the raw trytes that should be included in an +:py:meth:`Iota.attach_to_tangle` API request. diff --git a/iota/crypto/addresses.py b/iota/crypto/addresses.py index 88eb001d..8c335eb4 100644 --- a/iota/crypto/addresses.py +++ b/iota/crypto/addresses.py @@ -26,6 +26,24 @@ class AddressGenerator(Iterable[Address]): Note also that :py:meth:`iota.api.IotaApi.get_new_addresses` uses ``AddressGenerator`` internally, so you get the best of both worlds when you use the API (: + + :param TrytesCompatible seed: + The seed to derive addresses from. + + :param int security_level: + When generating a new address, you can specify a security level for it. + The security level of an address affects how long the private key is, + how secure a spent address is against brute-force attacks, and how many + transactions are needed to contain the signature. + + Could be either 1, 2 or 3. + + Reference: + + - https://docs.iota.org/docs/getting-started/0.1/clients/security-levels + + :param bool checksum: + Whether to generate address with or without checksum. """ DEFAULT_SECURITY_LEVEL = 2 """ @@ -90,6 +108,8 @@ def get_addresses(self, start, count=1, step=1): This may be any non-zero (positive or negative) integer. :return: + ``List[Address]`` + Always returns a list, even if only one address is generated. The returned list will contain ``count`` addresses, except diff --git a/iota/crypto/types.py b/iota/crypto/types.py index 6b2664b9..e0597db8 100644 --- a/iota/crypto/types.py +++ b/iota/crypto/types.py @@ -21,11 +21,20 @@ class Digest(TryteString): """ A private key digest. Basically the same thing as a regular - :py:class:`TryteString`, except that it (usually) has a key index + :py:class:`iota.TryteString`, except that it (usually) has a key index associated with it. Note: in a few cases (e.g., generating multisig addresses), a key index is not necessary/available. + + :param TrytesCompatible trytes: + Byte string or bytearray. + + :param Optional[int] key_index: + Key index used for generating the digest. + + :raises ValueError: + if length of ``trytes`` is not multiple of :py:attr:`iota.Hash.LEN`. """ def __init__(self, trytes, key_index=None): @@ -57,11 +66,26 @@ def security_level(self): """ Returns the number of iterations that were used to generate this digest (also known as "security level"). + + :return: + ``int`` """ return len(self) // Hash.LEN def as_json_compatible(self): # type: () -> dict + """ + Returns a JSON-compatible representation of the digest. + + :return: + ``dict`` with the following structure:: + + { + trytes': Text, + 'key_index': int, + } + + """ return { 'trytes': self._trytes.decode('ascii'), 'key_index': self.key_index, @@ -70,13 +94,19 @@ def as_json_compatible(self): class Seed(TryteString): """ - A TryteString that acts as a seed for crypto functions. + An :py:class:`iota.TryteString` that acts as a seed for crypto functions. - Note: This class is identical to :py:class:`TryteString`, but it has + Note: This class is identical to :py:class:`iota.TryteString`, but it has a distinct type so that seeds can be identified in Python code. IMPORTANT: For maximum security, a seed must be EXACTLY 81 trytes! + :param TrytesCompatible trytes: + Byte string or bytearray. + + :raises Warning: + if ``trytes`` are longer than 81 trytes in length. + References: - https://iota.stackexchange.com/q/249 @@ -101,7 +131,7 @@ def random(cls, length=Hash.LEN): """ Generates a random seed using a CSPRNG. - :param length: + :param int length: Length of seed, in trytes. For maximum security, this should always be set to 81, but @@ -109,14 +139,39 @@ def random(cls, length=Hash.LEN): doing. See https://iota.stackexchange.com/q/249 for more info. + + :return: + :py:class:`iota.Seed` object. + + Example usage:: + + from iota import Seed + + my_seed = Seed.random() + + print(my_seed) + """ return super(Seed, cls).random(length) class PrivateKey(TryteString): """ - A TryteString that acts as a private key, e.g., for generating + An :py:class:`iota.TryteString` that acts as a private key, e.g., for generating message signatures, new addresses, etc. + + :param TrytesCompatible trytes: + Byte string or bytearray. + + :param Optional[int] key_index: + Key index used for generating the private key. + + :param Optional[int] security_level: + Security level used for generating the private key. + + :raises ValueError: + if length of ``trytes`` is not a multiple of + :py:attr:`iota.transaction.Fragement.LEN`. """ def __init__(self, trytes, key_index=None, security_level=None): @@ -143,6 +198,19 @@ def __init__(self, trytes, key_index=None, security_level=None): def as_json_compatible(self): # type: () -> dict + """ + Returns a JSON-compatible representation of the private key. + + :return: + ``dict`` with the following structure:: + + { + trytes': Text, + 'key_index': int, + 'security_level': int, + } + + """ return { 'trytes': self._trytes.decode('ascii'), 'key_index': self.key_index, @@ -160,6 +228,9 @@ def get_digest(self): The digest is essentially the result of running the signing key through a PBKDF, yielding a constant-length hash that can be used for crypto. + + :return: + :py:class:`iota.crypto.types.Digest` object. """ hashes_per_fragment = FRAGMENT_LENGTH // Hash.LEN @@ -209,14 +280,21 @@ def sign_input_transactions(self, bundle, start_index): """ Signs the inputs starting at the specified index. - :param bundle: + :param Bundle bundle: The bundle that contains the input transactions to sign. - :param start_index: + :param int start_index: The index of the first input transaction. If necessary, the resulting signature will be split across subsequent transactions automatically. + + :raises ValuError: + - if ``bundle`` is not finalized. + - if attempting to sign non-input transactions. + - if attempting to sign transactions with non-empty + ``signature_message_fragment`` field. + :raises IndexError: if wrong ``start_index`` is provided. """ if not bundle.hash: diff --git a/iota/transaction/base.py b/iota/transaction/base.py index 776dba4d..c904c700 100644 --- a/iota/transaction/base.py +++ b/iota/transaction/base.py @@ -23,6 +23,60 @@ class Transaction(JsonSerializable): """ A transaction that has been attached to the Tangle. + + :param Optional[TransactionHash] hash_: + Transaction ID + + :param Optional[Fragment] signature_message_fragment: + Signature or message fragment. + + :param Address address: + The address associated with this transaction. + + :param int value: + Value of the transaction in iotas. Can be negative as well + (spending from address). + + :param int timestamp: + Unix timestamp in seconds. + + :param Optional[int] current_index: + Index of the transaction within the bundle. + + :param Optional[int] last_index: + Index of head transaction in the bundle. + + :param Optional[BundleHash] bundle_hash: + Bundle hash of the bundle containing the transaction. + + :param Optional[TransactionHash] trunk_transaction_hash: + Hash of trunk transaction. + + :param Optional[TransactionHash] branch_transaction_hash: + Hash of branch transaction. + + :param Optional[Tag] tag: + Optional classification tag applied to this transaction. + + :param Optional[int] attachment_timestamp: + Unix timestamp in milliseconds, decribes when the proof-of-work for this + transaction was done. + + :param Optional[int] attachment_timestamp_lower_bound: + Unix timestamp in milliseconds, lower bound of attachment. + + :param Optional[int] attachment_timestamp_upper_bound: + Unix timestamp in milliseconds, upper bound of attachment. + + :param Optional[Nonce] nonce: + Unique value used to increase security of the transaction hash. Result of + the proof-of-work aglorithm. + + :param Optional[Tag] legacy_tag: + Optional classification legacy_tag applied to this transaction. + + :return: + :py:class:`Transaction` object. """ @classmethod @@ -31,14 +85,67 @@ def from_tryte_string(cls, trytes, hash_=None): """ Creates a Transaction object from a sequence of trytes. - :param trytes: + :param TrytesCompatible trytes: Raw trytes. Should be exactly 2673 trytes long. - :param hash_: + :param Optional[TransactionHash] hash_: The transaction hash, if available. If not provided, it will be computed from the transaction trytes. + + :return: + :py:class:`Transaction` object. + + Example usage:: + + from iota import Transaction + + txn =\\ + Transaction.from_tryte_string( + b'GYPRVHBEZOOFXSHQBLCYW9ICTCISLHDBNMMVYD9JJHQMPQCTIQAQTJNNNJ9IDXLRCC' + b'OYOXYPCLR9PBEY9ORZIEPPDNTI9CQWYZUOTAVBXPSBOFEQAPFLWXSWUIUSJMSJIIIZ' + b'WIKIRH9GCOEVZFKNXEVCUCIIWZQCQEUVRZOCMEL9AMGXJNMLJCIA9UWGRPPHCEOPTS' + b'VPKPPPCMQXYBHMSODTWUOABPKWFFFQJHCBVYXLHEWPD9YUDFTGNCYAKQKVEZYRBQRB' + b'XIAUX9SVEDUKGMTWQIYXRGSWYRK9SRONVGTW9YGHSZRIXWGPCCUCDRMAXBPDFVHSRY' + b'WHGB9DQSQFQKSNICGPIPTRZINYRXQAFSWSEWIFRMSBMGTNYPRWFSOIIWWT9IDSELM9' + b'JUOOWFNCCSHUSMGNROBFJX9JQ9XT9PKEGQYQAWAFPRVRRVQPUQBHLSNTEFCDKBWRCD' + b'X9EYOBB9KPMTLNNQLADBDLZPRVBCKVCYQEOLARJYAGTBFR9QLPKZBOYWZQOVKCVYRG' + b'YI9ZEFIQRKYXLJBZJDBJDJVQZCGYQMROVHNDBLGNLQODPUXFNTADDVYNZJUVPGB9LV' + b'PJIYLAPBOEHPMRWUIAJXVQOEM9ROEYUOTNLXVVQEYRQWDTQGDLEYFIYNDPRAIXOZEB' + b'CS9P99AZTQQLKEILEVXMSHBIDHLXKUOMMNFKPYHONKEYDCHMUNTTNRYVMMEYHPGASP' + b'ZXASKRUPWQSHDMU9VPS99ZZ9SJJYFUJFFMFORBYDILBXCAVJDPDFHTTTIYOVGLRDYR' + b'TKHXJORJVYRPTDH9ZCPZ9ZADXZFRSFPIQKWLBRNTWJHXTOAUOL9FVGTUMMPYGYICJD' + b'XMOESEVDJWLMCVTJLPIEKBE9JTHDQWV9MRMEWFLPWGJFLUXI9BXPSVWCMUWLZSEWHB' + b'DZKXOLYNOZAPOYLQVZAQMOHGTTQEUAOVKVRRGAHNGPUEKHFVPVCOYSJAWHZU9DRROH' + b'BETBAFTATVAUGOEGCAYUXACLSSHHVYDHMDGJP9AUCLWLNTFEVGQGHQXSKEMVOVSKQE' + b'EWHWZUDTYOBGCURRZSJZLFVQQAAYQO9TRLFFN9HTDQXBSPPJYXMNGLLBHOMNVXNOWE' + b'IDMJVCLLDFHBDONQJCJVLBLCSMDOUQCKKCQJMGTSTHBXPXAMLMSXRIPUBMBAWBFNLH' + b'LUJTRJLDERLZFUBUSMF999XNHLEEXEENQJNOFFPNPQ9PQICHSATPLZVMVIWLRTKYPI' + b'XNFGYWOJSQDAXGFHKZPFLPXQEHCYEAGTIWIJEZTAVLNUMAFWGGLXMBNUQTOFCNLJTC' + b'DMWVVZGVBSEBCPFSM99FLOIDTCLUGPSEDLOKZUAEVBLWNMODGZBWOVQT9DPFOTSKRA' + b'BQAVOQ9RXWBMAKFYNDCZOJGTCIDMQSQQSODKDXTPFLNOKSIZEOY9HFUTLQRXQMEPGO' + b'XQGLLPNSXAUCYPGZMNWMQWSWCKAQYKXJTWINSGPPZG9HLDLEAWUWEVCTVRCBDFOXKU' + b'ROXH9HXXAXVPEJFRSLOGRVGYZASTEBAQNXJJROCYRTDPYFUIQJVDHAKEG9YACV9HCP' + b'JUEUKOYFNWDXCCJBIFQKYOXGRDHVTHEQUMHO999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999RKWEEVD99A99999999A99999999NFDPEEZCWVYLKZGSLCQNOFUSENI' + b'XRHWWTZFBXMPSQHEDFWZULBZFEOMNLRNIDQKDNNIELAOXOVMYEI9PGTKORV9IKTJZQ' + b'UBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSKUCUEMD9M9SQJ999' + b'999TKORV9IKTJZQUBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSK' + b'UCUEMD9M9SQJ999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999' + ) + """ tryte_string = TransactionTrytes(trytes) @@ -98,75 +205,123 @@ def __init__( ): self.hash = hash_ """ - Transaction ID, generated by taking a hash of the transaction - trits. + The transaction hash, used to uniquely identify the transaction on the + Tangle. + + This value is generated by taking a hash of the raw transaction trits. + + :type: :py:class:`TransactionHash` """ self.bundle_hash = bundle_hash """ - Bundle hash, generated by taking a hash of metadata from all the + The bundle hash, used to identify transactions that are part of the same + bundle. + + This value is generated by taking a hash of the metadata from all transactions in the bundle. + + :type: :py:class:`BundleHash` """ self.address = address """ The address associated with this transaction. - If ``value`` is != 0, the associated address' balance is + Depending on the transaction's ``value``, this address may be a sender + or a recipient. If ``value`` is != 0, the associated address' balance is adjusted as a result of this transaction. + + :type: :py:class:`Address` """ self.value = value """ - Amount to adjust the balance of ``address``. - Can be negative (i.e., for spending inputs). + The number of iotas being transferred in this transaction: + + - If this value is negative, then the ``address`` is spending iotas. + - If it is positive, then the ``address`` is receiving iotas. + - If it is zero, then this transaction is being used to carry metadata + (such as a signature fragment or a message) instead of transferring + iotas. + + :type: ``int`` """ self._legacy_tag = legacy_tag """ - Optional classification legacy_tag applied to this transaction. + A short message attached to the transaction. + + .. warning:: + Deprecated, use :py:attr:`Transaction.tag` instead. + + :type: :py:class:`Tag` """ self.nonce = nonce """ Unique value used to increase security of the transaction hash. + + This is the product of the PoW process. + + :type: :py:class:`Nonce` """ self.timestamp = timestamp """ Timestamp used to increase the security of the transaction hash. + Describes when the transaction was created. + .. important:: This value is easy to forge! Do not rely on it when resolving conflicts! + + :type: ``int``, unix timestamp in seconds. """ self.current_index = current_index """ The position of the transaction inside the bundle. + - If the ``current_index`` value is 0, then this is the "head transaction". + - If it is equal to ``last_index``, then this is the "tail transaction". + For value transfers, the "spend" transaction is generally in the 0th position, followed by inputs, and the "change" transaction is last. + + :type: ``int`` """ self.last_index = last_index """ - The position of the final transaction inside the bundle. + The index of the final transaction in the bundle. + + This value is attached to every transaction to make it easier to + traverse and verify bundles. + + :type: ``int`` """ self.trunk_transaction_hash = trunk_transaction_hash """ + The transaction hash of the next transaction in the bundle. + In order to add a transaction to the Tangle, the client must perform PoW to "approve" two existing transactions, called the "trunk" and "branch" transactions. The trunk transaction is generally used to link transactions within a bundle. + + :type: :py:class:`TransactionHash` """ self.branch_transaction_hash = branch_transaction_hash """ + An unrelated transaction that this transaction "approves". + In order to add a transaction to the Tangle, the client must perform PoW to "approve" two existing transactions, called the "trunk" and "branch" transactions. @@ -174,18 +329,41 @@ def __init__( The branch transaction may be selected strategically to maximize the bundle's chances of getting confirmed; otherwise it usually has no significance. + + :type: :py:class:`TransactionHash` """ self.tag = tag """ Optional classification tag applied to this transaction. + + Many transactions have empty tags (``Tag(b'999999999999999999999999999')``). + + :type: :py:class:`Tag` """ self.attachment_timestamp = attachment_timestamp + """ + Estimated epoch time of the attachment to the tangle. + + Decribes when the proof-of-work for this transaction was done. + + :type: ``int``, unix timestamp in milliseconds, + """ self.attachment_timestamp_lower_bound = attachment_timestamp_lower_bound + """ + The lowest possible epoch time of the attachment to the tangle. + + :type: ``int``, unix timestamp in milliseconds. + """ self.attachment_timestamp_upper_bound = attachment_timestamp_upper_bound + """ + The highest possible epoch time of the attachment to the tangle. + + :type: ``int``, unix timestamp in milliseconds. + """ self.signature_message_fragment = signature_message_fragment """ @@ -203,6 +381,8 @@ def __init__( pretty much any value. Like signatures, the message may be split across multiple transactions if it is too large to fit inside a single transaction. + + :type: :py:class:`Fragment` """ self.is_confirmed = None # type: Optional[bool] @@ -210,10 +390,12 @@ def __init__( Whether this transaction has been confirmed by neighbor nodes. Must be set manually via the ``getInclusionStates`` API command. + :type: ``Optional[bool]`` + References: - - :py:meth:`iota.api.StrictIota.get_inclusion_states` - - :py:meth:`iota.api.Iota.get_transfers` + - :py:meth:`Iota.get_inclusion_states` + - :py:meth:`Iota.get_transfers` """ @property @@ -312,6 +494,28 @@ def as_json_compatible(self): """ Returns a JSON-compatible representation of the object. + :return: + ``dict`` with the following structure:: + + { + 'hash_': TransactionHash, + 'signature_message_fragment': Fragment, + 'address': Address, + 'value': int, + 'legacy_tag': Tag, + 'timestamp': int, + 'current_index': int, + 'last_index': int, + 'bundle_hash': BundleHash, + 'trunk_transaction_hash': TransactionHash, + 'branch_transaction_hash': TransactionHash, + 'tag': Tag, + 'attachment_timestamp': int, + 'attachment_timestamp_lower_bound': int, + 'attachment_timestamp_upper_bound': int, + 'nonce': Nonce, + } + References: - :py:class:`iota.json.JsonEncoder`. @@ -344,6 +548,9 @@ def as_tryte_string(self): # type: () -> TransactionTrytes """ Returns a TryteString representation of the transaction. + + :return: + :py:class:`TryteString` object. """ return TransactionTrytes( self.signature_message_fragment @@ -368,6 +575,9 @@ def get_signature_validation_trytes(self): """ Returns the values needed to validate the transaction's ``signature_message_fragment`` value. + + :return: + :py:class:`TryteString` object. """ return ( self.address.address @@ -399,9 +609,16 @@ class Bundle(JsonSerializable, Sequence[Transaction]): Instead, Bundles must be inferred by following linked transactions with the same bundle hash. + :param Optional[Iterable[Transaction]] transactions: + Transactions in the bundle. Note that transactions will be sorted into + ascending order based on their ``current_index``. + + :return: + :py:class:`Bundle` object. + References: - - :py:class:`iota.commands.extended.get_bundles.GetBundlesCommand` + - :py:class:`Iota.get_transfers` """ @classmethod @@ -409,6 +626,26 @@ def from_tryte_strings(cls, trytes): # type: (Iterable[TryteString]) -> Bundle """ Creates a Bundle object from a list of tryte values. + + Note, that this is effectively calling + :py:meth:`Transaction.from_tryte_string` on the iterbale elements and + constructing the bundle from the created transactions. + + :param Iterable[TryteString] trytes: + List of raw transaction trytes. + + :return: + :py:class:`Bundle` object. + + Example usage:: + + from iota import Bundle + bundle = Bundle.from_tryte_strings([ + b'GYPRVHBEZOOFXSHQBLCYW9ICTCISLHDBNMMVYD9JJHQMPQCTIQAQTJNNNJ9IDXLRCC...', + b'OYOXYPCLR9PBEY9ORZIEPPDNTI9CQWYZUOTAVBXPSBOFEQAPFLWXSWUIUSJMSJIIIZ...', + # etc. + ]) + """ return cls(map(Transaction.from_tryte_string, trytes)) @@ -417,6 +654,9 @@ def __init__(self, transactions=None): super(Bundle, self).__init__() self.transactions = [] # type: List[Transaction] + """ + List of :py:class:`Transaction` objects that are in the bundle. + """ if transactions: self.transactions.extend( sorted(transactions, key=attrgetter('current_index')), @@ -429,7 +669,7 @@ def __init__(self, transactions=None): References: - - :py:class:`iota.commands.extended.get_transfers.GetTransfersCommand` + - :py:class:`Iota.get_transfers` """ def __contains__(self, transaction): @@ -457,9 +697,11 @@ def is_confirmed(self): This attribute must be set manually. + :return: ``bool`` + References: - - :py:class:`iota.commands.extended.get_transfers.GetTransfersCommand` + - :py:class:`Iota.get_transfers` """ return self._is_confirmed @@ -483,7 +725,9 @@ def hash(self): This value is determined by inspecting the bundle's tail transaction, so in a few edge cases, it may be incorrect. - If the bundle has no transactions, this method returns ``None``. + :return: + - :py:class:`BundleHash` object, or + - If the bundle has no transactions, this method returns ``None``. """ try: return self.tail_transaction.bundle_hash @@ -495,6 +739,8 @@ def tail_transaction(self): # type: () -> Transaction """ Returns the tail transaction of the bundle. + + :return: :py:class:`Transaction` """ return self[0] @@ -504,7 +750,7 @@ def get_messages(self, errors='drop'): Attempts to decipher encoded messages from the transactions in the bundle. - :param errors: + :param Text errors: How to handle trytes that can't be converted, or bytes that can't be decoded using UTF-8: @@ -519,6 +765,8 @@ def get_messages(self, errors='drop'): 'ignore' Omit the invalid tryte/byte sequence. + + :return: ``List[Text]`` """ decode_errors = 'strict' if errors == 'drop' else errors @@ -548,7 +796,7 @@ def as_tryte_strings(self, head_to_tail=False): Returns TryteString representations of the transactions in this bundle. - :param head_to_tail: + :param bool head_to_tail: Determines the order of the transactions: - ``True``: head txn first, tail txn last. @@ -556,6 +804,8 @@ def as_tryte_strings(self, head_to_tail=False): Note that the order is reversed by default, as this is the way bundles are typically broadcast to the Tangle. + + :return: ``List[TransactionTrytes]`` """ transactions = self if head_to_tail else reversed(self) return [t.as_tryte_string() for t in transactions] @@ -565,6 +815,10 @@ def as_json_compatible(self): """ Returns a JSON-compatible representation of the object. + :return: + ``List[dict]``. The ``dict`` list elements contain individual + transactions as in :py:meth:`Transaction.as_json_compatible`. + References: - :py:class:`iota.json.JsonEncoder`. @@ -575,6 +829,9 @@ def group_transactions(self): # type: () -> List[List[Transaction]] """ Groups transactions in the bundle by address. + + :return: + ``List[List[Transaction]]`` """ groups = [] diff --git a/iota/transaction/creation.py b/iota/transaction/creation.py index edc900eb..302e5f8e 100644 --- a/iota/transaction/creation.py +++ b/iota/transaction/creation.py @@ -28,8 +28,52 @@ class ProposedTransaction(Transaction): """ A transaction that has not yet been attached to the Tangle. - Provide to :py:meth:`iota.api.Iota.send_transfer` to attach to - tangle and publish/store. + Proposed transactions are created locally. Note that for creation, only a + small subset of the :py:class:`Transaction` attributes is needed. + + Provide to :py:meth:`Iota.send_transfer` to attach to tangle and + publish/store. + + .. note:: + In order to follow naming convention of other libs, you may use the + name ``Transfer`` interchangeably with ``ProposedTransaction``. + See https://github.com/iotaledger/iota.py/issues/72 for more info. + + :param Address address: + Address associated with the transaction. + + :param int value: + Transaction value. + + :param Optional[Tag] tag: + Optional classification tag applied to this transaction. + + :param Optional[TryteString] message: + Message to be included in + :py:attr:`transaction.Transaction.signature_or_message_fragment` field + of the transaction. Should not be longer than + :py:attr:`transaction.Fragment.LEN`. + + :param Optional[int] timestamp: + Timestamp of transaction creation. If not supplied, the library will + generate it. + + :return: + :py:class:`iota.ProposedTransaction` object. + + Example usage:: + + txn=\\ + ProposedTransaction( + address = + Address( + b'TESTVALUE9DONTUSEINPRODUCTION99999XE9IVG' + b'EFNDOCQCMERGUATCIEGGOHPHGFIAQEZGNHQ9W99CH' + ), + message = TryteString.from_unicode('thx fur cheezburgers'), + tag = Tag(b'KITTENS'), + value = 42, + ) """ def __init__( @@ -73,6 +117,13 @@ def as_tryte_string(self): # type: () -> TryteString """ Returns a TryteString representation of the transaction. + + :return: + :py:class:`TryteString` object. + + :raises RuntimeError: + if the transaction doesn't have a bundle hash field, meaning that + the bundle containing the transaction hasn't been finalized yet. """ if not self.bundle_hash: raise with_context( @@ -116,6 +167,25 @@ class ProposedBundle(Bundle, Sequence[ProposedTransaction]): """ A collection of proposed transactions, to be treated as an atomic unit when attached to the Tangle. + + :param Optional[Iterable[ProposedTransaction]] transactions: + Proposed transactions that should be put into the proposed bundle. + + :param Optional[Iterable[Address]] inputs: + Addresses that hold iotas to fund outgoing transactions in the bundle. + If provided, the library will create and sign withdrawing transactions + from these addresses. + + See :py:meth:`Iota.get_inputs` for more info. + + :param Optional[Address] change_address: + Due to the signatures scheme of IOTA, you can only spend once from an + address. Therefore the library will always deduct the full available + amount from an input address. The unused tokens will be sent to + ``change_address`` if provided, or to a newly-generated and unused + address if not. + + :return: :py:class:`ProposedBundle` """ def __init__( @@ -141,6 +211,8 @@ def __bool__(self): # type: () -> bool """ Returns whether this bundle has any transactions. + + :return: ``bool`` """ return bool(self._transactions) @@ -187,6 +259,8 @@ def balance(self): - A negative balance means that there are unspent inputs; use :py:meth:`send_unspent_inputs_to` to send the unspent inputs to a "change" address. + + :return: ``bool`` """ return sum(t.value for t in self._transactions) @@ -195,6 +269,8 @@ def tag(self): # type: () -> Tag """ Determines the most relevant tag for the bundle. + + :return: :py:class:`transaction.Tag` """ for txn in reversed(self): # type: ProposedTransaction if txn.tag: @@ -207,6 +283,10 @@ def as_json_compatible(self): """ Returns a JSON-compatible representation of the object. + :return: + ``List[dict]``. The ``dict`` list elements contain individual + transactions as in :py:meth:`ProposedTransaction.as_json_compatible`. + References: - :py:class:`iota.json.JsonEncoder`. @@ -220,6 +300,14 @@ def add_transaction(self, transaction): If the transaction message is too long, it will be split automatically into multiple transactions. + + :param ProposedTransaction transaction: + The transaction to be added. + + :raises RuntimeError: if bundle is already finalized + :raises ValueError: + if trying to add a spending transaction. Use :py:meth:`add_inputs` + instead. """ if self.hash: raise RuntimeError('Bundle is already finalized.') @@ -253,17 +341,26 @@ def add_transaction(self, transaction): def add_inputs(self, inputs): # type: (Iterable[Address]) -> None """ - Adds inputs to spend in the bundle. + Specifies inputs that can be used to fund transactions that spend iotas. + + The :py:class:`ProposedBundle` will use these to create the necessary + input transactions. Note that each input may require multiple transactions, in order to hold the entire signature. - :param inputs: + :param Iterable[Address] inputs: Addresses to use as the inputs for this bundle. .. important:: Must have ``balance`` and ``key_index`` attributes! - Use :py:meth:`iota.api.get_inputs` to prepare inputs. + Use :py:meth:`Iota.get_inputs` to prepare inputs. + + :raises RuntimeError: if bundle is already finalized. + :raises ValueError: + - if input address has no ``balance``. + - if input address has no ``key_index``. + """ if self.hash: raise RuntimeError('Bundle is already finalized.') @@ -302,10 +399,17 @@ def add_inputs(self, inputs): def send_unspent_inputs_to(self, address): # type: (Address) -> None """ - Adds a transaction to send "change" (unspent inputs) to the - specified address. + Specifies the address that will receive unspent iotas. + + The :py:class:`ProposedBundle` will use this to create the necessary + change transaction, if necessary. If the bundle has no unspent inputs, this method does nothing. + + :param Address address: + Address to send unspent inputs to. + + :raises RuntimeError: if bundle is already finalized. """ if self.hash: raise RuntimeError('Bundle is already finalized.') @@ -316,6 +420,20 @@ def finalize(self): # type: () -> None """ Finalizes the bundle, preparing it to be attached to the Tangle. + + This operation includes checking if the bundle has zero balance, + generating the bundle hash and updating the transactions with it, + furthermore to initialize signature/message fragment fields. + + Once this method is invoked, no new transactions may be added to the + bundle. + + :raises RuntimeError: if bundle is already finalized. + :raises ValueError: + - if bundle has no transactions. + - if bundle has unspent inputs (there is no ``change_address`` + attribute specified.) + - if inputs are insufficient to cover bundle spend. """ if self.hash: raise RuntimeError('Bundle is already finalized.') @@ -387,6 +505,23 @@ def sign_inputs(self, key_generator): # type: (KeyGenerator) -> None """ Sign inputs in a finalized bundle. + + Generates the necessary cryptographic signatures to authorize spending + the inputs. + + .. note:: + You do not need to invoke this method if the bundle does + not contain any transactions that spend iotas. + + :param KeyGenerator key_generator: + Generator to create private keys for signing. + + :raises RuntimeError: if bundle is not yet finalized. + :raises ValueError: + - if the input transaction specifies an address that doesn't have + ``key_index`` attribute defined. + - if the input transaction specifies an address that doesn't have + ``security_level`` attribute defined. """ if not self.hash: raise RuntimeError('Cannot sign inputs until bundle is finalized.') @@ -442,7 +577,7 @@ def sign_input_at(self, start_index, private_key): """ Signs the input at the specified index. - :param start_index: + :param int start_index: The index of the first input transaction. If necessary, the resulting signature will be split across @@ -450,13 +585,15 @@ def sign_input_at(self, start_index, private_key): ``security_level=2``, you still only need to call :py:meth:`sign_input_at` once). - :param private_key: + :param PrivateKey private_key: The private key that will be used to generate the signature. .. important:: Be sure that the private key was generated using the correct seed, or the resulting signature will be invalid! + + :raises RuntimeError: if bundle is not yet finalized. """ if not self.hash: raise RuntimeError('Cannot sign inputs until bundle is finalized.') @@ -467,6 +604,9 @@ def _create_input_transactions(self, addy): # type: (Address) -> None """ Creates transactions for the specified input address. + + :param Address addy: + Input address. """ self._transactions.append(ProposedTransaction( address=addy, @@ -510,6 +650,15 @@ def add_signature_or_message( :param int start_index: Index of transaction in bundle from where addition shoudl start. + + :raise RuntimeError: if bundle is already finalized. + :raise ValueError: + - if empty list is provided for ``fragments`` + - if wrong ``start_index`` is provided. + - if ``fragments`` is too long and does't fit into the bundle. + :raise TypeError: + - if ``fragments`` is not an ``Iterable`` + - if ``fragments`` contains other types than :py:class:`Fragment`. """ if self.hash: raise RuntimeError('Bundle is already finalized.') diff --git a/iota/transaction/types.py b/iota/transaction/types.py index a76bea19..caba9738 100644 --- a/iota/transaction/types.py +++ b/iota/transaction/types.py @@ -17,21 +17,24 @@ class BundleHash(Hash): """ - A TryteString that acts as a bundle hash. + An :py:class:`TryteString` (:py:class:`Hash`) that acts as a bundle hash. """ pass class TransactionHash(Hash): """ - A TryteString that acts as a transaction hash. + An :py:class:`TryteString` (:py:class:`Hash`) that acts as a transaction hash. """ pass class Fragment(TryteString): """ - A signature/message fragment in a transaction. + An :py:class:`TryteString` representation of a signature/message fragment + in a transaction. + + :raises ValueError: if ``trytes`` is longer than 2187 trytes in length. """ LEN = FRAGMENT_LENGTH """ @@ -57,7 +60,9 @@ def __init__(self, trytes): class TransactionTrytes(TryteString): """ - A TryteString representation of a Transaction. + An :py:class:`TryteString` representation of a Transaction. + + :raises ValueError: if ``trytes`` is longer than 2673 trytes in length. """ LEN = 2673 """ @@ -83,7 +88,9 @@ def __init__(self, trytes): class Nonce(TryteString): """ - A TryteString that acts as a transaction nonce. + An :py:class:`TryteString` that acts as a transaction nonce. + + :raises ValueError: if ``trytes`` is longer than 27 trytes in length. """ LEN = 27 """ diff --git a/iota/types.py b/iota/types.py index beeab33e..5f5567c0 100644 --- a/iota/types.py +++ b/iota/types.py @@ -40,13 +40,31 @@ class TryteString(JsonSerializable): """ A string representation of a sequence of trytes. - A tryte string is similar in concept to Python's byte string, except - it has a more limited alphabet. Byte strings are limited to ASCII - (256 possible values), while the tryte string alphabet only has 27 - characters (one for each possible tryte configuration). + A :py:class:`TryteString` is an ASCII representation of a sequence of trytes. + In many respects, it is similar to a Python ``bytes`` object (which is an + ASCII representation of a sequence of bytes). + + In fact, the two objects behave very similarly; they support + concatenation, comparison, can be used as dict keys, etc. + + However, unlike ``bytes``, a :py:class:`TryteString` can only contain + uppercase letters and the number 9 (as a regular expression: ``^[A-Z9]*$``). .. important:: A TryteString does not represent a numeric value! + + :param TrytesCompatible trytes: + Byte string or bytearray. + + :param Optional[int] pad: + Ensure at least this many trytes. + + If there are too few, null trytes will be appended to the + TryteString. + + .. note:: + If the TryteString is too long, it will *not* be + truncated! """ @classmethod @@ -55,8 +73,11 @@ def random(cls, length): """ Generates a random sequence of trytes. - :param length: + :param int length: Number of trytes to generate. + + :return: + :py:class:`TryteString` object. """ alphabet = list(itervalues(AsciiTrytesCodec.alphabet)) generator = SystemRandom() @@ -77,12 +98,13 @@ def from_bytes(cls, bytes_, codec=AsciiTrytesCodec.name, *args, **kwargs): """ Creates a TryteString from a sequence of bytes. - :param bytes_: - Source bytes. + :param Union[binary_type,bytearray] bytes\_: + Source bytes. ASCII representation of a sequence of bytes. + Note that only tryte alphabet supported! - :param codec: + :param Text codec: Reserved for future use. - + Currently supports only the 'trytes_ascii' codec. See https://github.com/iotaledger/iota.py/issues/62 for more information. @@ -91,6 +113,15 @@ def from_bytes(cls, bytes_, codec=AsciiTrytesCodec.name, *args, **kwargs): :param kwargs: Additional keyword arguments to pass to the initializer. + + :return: + :py:class:`TryteString` object. + + Example usage:: + + from iota import TryteString + message_trytes = TryteString.from_bytes(b'HELLO999IOTA') + """ return cls(encode(bytes_, codec), *args, **kwargs) @@ -100,14 +131,23 @@ def from_unicode(cls, string, *args, **kwargs): """ Creates a TryteString from a Unicode string. - :param string: - Source string. + :param Text string: + Source Unicode string. :param args: Additional positional arguments to pass to the initializer. :param kwargs: Additional keyword arguments to pass to the initializer. + + :return: + :py:class:`TryteString` object. + + Example usage:: + + from iota import TryteString + message_trytes = TryteString.from_unicode('Hello, IOTA!') + """ return cls.from_bytes( bytes_=string.encode('utf-8'), @@ -138,7 +178,7 @@ def from_trytes(cls, trytes, *args, **kwargs): """ Creates a TryteString from a sequence of trytes. - :param trytes: + :param Iterable[Iterable[int]] trytes: Iterable of tryte values. In this context, a tryte is defined as a list containing 3 @@ -150,6 +190,25 @@ def from_trytes(cls, trytes, *args, **kwargs): :param kwargs: Additional keyword arguments to pass to the initializer. + :return: + :py:class:`TryteString` object. + + Example usage:: + + from iota import TryteString + message_trytes = TryteString.from_trytes( + [ + [1, 0, -1], + [-1, 1, 0], + [1, -1, 0], + [-1, 1, 0], + [0, 1, 0], + [0, 1, 0], + [-1, 1, 1], + [-1, 1, 0], + ] + ) + References: - :py:meth:`as_trytes` @@ -173,7 +232,7 @@ def from_trits(cls, trits, *args, **kwargs): """ Creates a TryteString from a sequence of trits. - :param trits: + :param Iterable[int] trits: Iterable of trit values (-1, 0, 1). :param args: @@ -182,6 +241,16 @@ def from_trits(cls, trits, *args, **kwargs): :param kwargs: Additional keyword arguments to pass to the initializer. + :return: + :py:class:`TryteString` object. + + Example usage:: + + from iota import TryteString + message_trytes = TryteString.from_trits( + [1, 0, -1, -1, 1, 0, 1, -1, 0, -1, 1, 0, 0, 1, 0, 0, 1, 0, -1, 1, 1, -1, 1, 0] + ) + References: - :py:func:`int_from_trits` @@ -206,10 +275,10 @@ def from_trits(cls, trits, *args, **kwargs): def __init__(self, trytes, pad=None): # type: (TrytesCompatible, Optional[int]) -> None """ - :param trytes: + :param TrytesCompatible trytes: Byte string or bytearray. - :param pad: + :param Optional[int] pad: Ensure at least this many trytes. If there are too few, null trytes will be appended to the @@ -484,7 +553,7 @@ def encode(self, errors='strict', codec=AsciiTrytesCodec.name): Encodes the TryteString into a lower-level primitive (usually bytes). - :param errors: + :param Text errors: How to handle trytes that can't be converted: 'strict' @@ -496,15 +565,40 @@ def encode(self, errors='strict', codec=AsciiTrytesCodec.name): 'ignore' omit the tryte from the result. - :param codec: + :param Text codec: Reserved for future use. See https://github.com/iotaledger/iota.py/issues/62 for more information. - :raise: + :raises: - :py:class:`iota.codecs.TrytesDecodeError` if the trytes cannot be decoded into bytes. + + :return: + Python ``bytes`` object. + + Example usage:: + + from iota import TryteString + + # Message payload as unicode string + message = 'Hello, iota!' + + # Create TryteString + message_trytes = TryteString.from_unicode(message) + + # Encode TryteString into bytes + encoded_message_bytes = message_trytes.encode() + + # This will be b'Hello, iota' + print(encoded_message_bytes) + + # Get the original message + decoded = encoded_message_bytes.decode() + + print(decoded == message) + """ # Converting ASCII-encoded trytes into bytes is considered to be # a *decode* operation according to @@ -533,7 +627,7 @@ def decode(self, errors='strict', strip_padding=True): Decodes the TryteString into a higher-level abstraction (usually Unicode characters). - :param errors: + :param Text errors: How to handle trytes that can't be converted, or bytes that can't be decoded using UTF-8: @@ -546,14 +640,26 @@ def decode(self, errors='strict', strip_padding=True): 'ignore' omit the invalid tryte/byte sequence. - :param strip_padding: + :param bool strip_padding: Whether to strip trailing null trytes before converting. - :raise: + :raises: - :py:class:`iota.codecs.TrytesDecodeError` if the trytes cannot be decoded into bytes. - :py:class:`UnicodeDecodeError` if the resulting bytes cannot be decoded using UTF-8. + + :return: + ``Unicode string`` object. + + Example usage:: + + from iota import TryteString + + trytes = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA') + + message = trytes.decode() + """ trytes = self._trytes if strip_padding and (trytes[-1] == ord(b'9')): @@ -586,6 +692,18 @@ def as_json_compatible(self): References: - :py:class:`iota.json.JsonEncoder`. + + :return: + JSON-compatible representation of the object (string). + + Example usage:: + + from iota import TryteString + + trytes = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA') + + json_payload = trytes.as_json_compatible() + """ return self._trytes.decode('ascii') @@ -595,6 +713,22 @@ def as_integers(self): Converts the TryteString into a sequence of integers. Each integer is a value between -13 and 13. + + See the + `tryte alphabet `_ + for more info. + + :return: + ``List[int]`` + + Example usage:: + + from iota import TryteString + + trytes = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA') + + tryte_ints = trytes.as_integers() + """ return [ self._normalize(AsciiTrytesCodec.index[c]) @@ -613,6 +747,18 @@ def as_trytes(self): .. important:: :py:class:`TryteString` is not a numeric type, so the result of this method should not be interpreted as an integer! + + :return: + ``List[List[int]]`` + + Example usage:: + + from iota import TryteString + + trytes = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA') + + tryte_list = trytes.as_trytes() + """ return [ trits_from_int(n, pad=3) @@ -633,6 +779,18 @@ def as_trits(self): .. important:: :py:class:`TryteString` is not a numeric type, so the result of this method should not be interpreted as an integer! + + :return: + ``List[int]`` + + Example usage:: + + from iota import TryteString + + trytes = TryteString(b'RBTC9D9DCDQAEASBYBCCKBFA') + + trits = trytes.as_trits() + """ # http://stackoverflow.com/a/952952/5568265#comment4204394_952952 return list(chain.from_iterable(self.as_trytes())) @@ -710,7 +868,7 @@ def __next__(self): """ Returns the next chunk in the iterator. - :raise: + :raises: - :py:class:`StopIteration` if there are no more chunks available. """ @@ -731,10 +889,19 @@ def __next__(self): class Hash(TryteString): """ - A TryteString that is exactly one hash long. + A :py:class:`TryteString` that is exactly one hash long. + + :param TrytesCompatible trytes: + Object to construct the hash from. + + :raises ValueError: if ``trytes`` is longer than 81 trytes. + """ # Divide by 3 to convert trits to trytes. LEN = HASH_LENGTH // TRITS_PER_TRYTE + """ + Length is always 81 trytes long. + """ def __init__(self, trytes): # type: (TrytesCompatible) -> None @@ -755,10 +922,31 @@ def __init__(self, trytes): class Address(TryteString): """ - A TryteString that acts as an address, with support for generating + A :py:class:`TryteString` that acts as an address, with support for generating and validating checksums. + + :param TrytesCompatible trytes: + Object to construct the address from. + + :param Optional[int] balance: + Known balance of the address. + + :param Optional[int] key_index: + Index of the address that was used during address generation. + Must be greater than zero. + + :param Optional[int] security_level: + Security level that was used during address generation. + Might be 1, 2 or 3. + + :raises + ValueError: if ``trytes`` is longer than 81 trytes, unless it is + exactly 90 trytes long (address + checksum). """ LEN = Hash.LEN + """ + Length of an address. + """ def __init__( self, @@ -794,6 +982,9 @@ def __init__( # Make the address sans checksum accessible. self.address = self[:self.LEN] # type: TryteString + """ + Address trytes without the checksum. + """ self.balance = balance """ @@ -802,14 +993,14 @@ def __init__( References: - - :py:class:`iota.commands.extended.get_inputs` + - :py:meth:`Iota.get_inputs` - :py:meth:`ProposedBundle.add_inputs` """ self.key_index = key_index """ Index of the key used to generate this address. - Defaults to ``None``; usually set via ``AddressGenerator``. + Defaults to ``None``; usually set via :py:class:`AddressGenerator`. References: @@ -823,6 +1014,32 @@ def __init__( """ def as_json_compatible(self): + """ + Returns a JSON-compatible representation of the Address. + + :return: + ``dict`` with the following structure:: + + { + 'trytes': Text, + 'balance': int, + 'key_index': int, + 'security_level': int, + } + + Example usage:: + + from iota import Address + + # Example address only, do not use in your code! + addy = Address( + b'LVHHIXQNYKWQMGXGLFOKOCDFHPKXAUKWMSZVDRAT' + b'TICUZXFACM9DNJELJGMLMK99KDVVOOWLINVBZIGWZ' + ) + + print(addy.as_json_compatible()) + + """ # type: () -> dict return { 'trytes': self._trytes.decode('ascii'), @@ -835,6 +1052,28 @@ def is_checksum_valid(self): # type: () -> bool """ Returns whether this address has a valid checksum. + + :return: + ``bool`` + + Example usage:: + + from iota import Address + + # Example address only, do not use in your code! + addy = Address( + b'LVHHIXQNYKWQMGXGLFOKOCDFHPKXAUKWMSZVDRAT' + b'TICUZXFACM9DNJELJGMLMK99KDVVOOWLINVBZIGWZ' + ) + + # Should be ``False`` + print(addy.is_checksum_valid()) + + addy.add_checksum() + + # Should be ``True`` + print(addy.is_checksum_valid()) + """ if self.checksum: return self.checksum == self._generate_checksum() @@ -845,6 +1084,27 @@ def with_valid_checksum(self): # type: () -> Address """ Returns the address with a valid checksum attached. + + :return: + :py:class:`Address` object. + + Example usage:: + + from iota import Address + + # Example address only, do not use in your code! + addy = Address( + b'LVHHIXQNYKWQMGXGLFOKOCDFHPKXAUKWMSZVDRAT' + b'TICUZXFACM9DNJELJGMLMK99KDVVOOWLINVBZIGWZ' + ) + + addy_with_checksum = addy.with_valid_checksum() + + print(addy_with_checksum) + + # Should be ``True`` + print(addy_with_checksum.is_checksum_valid()) + """ return Address( trytes=self.address + self._generate_checksum(), @@ -873,7 +1133,32 @@ def _generate_checksum(self): def add_checksum(self): # type: () -> None """ - Add checksum to :py:class:`Address` object. + Adds checksum to :py:class:`Address` object. + + :return: ``None`` + + Example usage:: + + from iota import Address + + # Example address only, do not use in your code! + addy = Address( + b'LVHHIXQNYKWQMGXGLFOKOCDFHPKXAUKWMSZVDRAT' + b'TICUZXFACM9DNJELJGMLMK99KDVVOOWLINVBZIGWZ' + ) + + # Should be ``False`` + print(addy.is_checksum_valid()) + + print(addy.checksum) + + addy.add_checksum() + + # Should be ``True`` + print(addy.is_checksum_valid()) + + print(addy.checksum) + """ if self.is_checksum_valid(): # Address already has a valid checksum. @@ -888,16 +1173,50 @@ def add_checksum(self): def remove_checksum(self): # type: () -> None """ - Remove checksum from :py:class:`Address` object. + Removes checksum from :py:class:`Address` object. + + :return: ``None`` + + Example usage:: + + from iota import Address + + # Example address only, do not use in your code! + addy = Address( + b'LVHHIXQNYKWQMGXGLFOKOCDFHPKXAUKWMSZVDRAT' + b'TICUZXFACM9DNJELJGMLMK99KDVVOOWLINVBZIGWZ' + b'AACAMCWUW' # 9 checksum trytes + ) + + # Should be ``True`` + print(addy.is_checksum_valid()) + + print(addy.checksum) + + addy.remove_checksum() + + # Should be ``False`` + print(addy.is_checksum_valid()) + + print(addy.checksum) + """ self.checksum = None self._trytes = self._trytes[:self.LEN] class AddressChecksum(TryteString): """ - A TryteString that acts as an address checksum. + A :py:class:`TryteString` that acts as an address checksum. + + :param TrytesCompatible trytes: + Checksum trytes. + + :raises ValueError: if ``trytes`` is not exactly 9 trytes in length. """ LEN = 9 + """ + Length of an address checksum. + """ def __init__(self, trytes): # type: (TrytesCompatible) -> None @@ -921,8 +1240,16 @@ def __init__(self, trytes): class Tag(TryteString): """ A TryteString that acts as a transaction tag. + + :param TrytesCompatible trytes: + Tag trytes. + + :raises ValueError: if ``trytes`` is longer than 27 trytes in length. """ LEN = 27 + """ + Length of a tag. + """ def __init__(self, trytes): # type: (TrytesCompatible) -> None From 3395a418d93c3980d0c853b1e12ef3037f0015ab Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Thu, 28 Nov 2019 13:13:17 +0100 Subject: [PATCH 14/20] Add `wereAddressesSpentFrom` check to `iter_used_addresses` and `get_new_addresses` So far only `findTransactions` check was made in the the two methods above, this changes is needed to achieve identical behavior with the JS lib. Based on @pdecol's implementation. --- docs/addresses.rst | 12 +-- iota/api.py | 5 +- iota/commands/extended/get_account_data.py | 2 +- iota/commands/extended/get_new_addresses.py | 18 ++-- iota/commands/extended/utils.py | 17 +++- .../extended/get_account_data_test.py | 25 ++++++ test/commands/extended/get_inputs_test.py | 76 ++++++++++------ .../extended/get_new_addresses_test.py | 87 ++++++++++++++++--- test/commands/extended/get_transfers_test.py | 23 ++++- .../extended/prepare_transfer_test.py | 20 ++++- 10 files changed, 220 insertions(+), 65 deletions(-) diff --git a/docs/addresses.rst b/docs/addresses.rst index c3403ae2..01fa0fc8 100644 --- a/docs/addresses.rst +++ b/docs/addresses.rst @@ -17,17 +17,11 @@ any other financial service. These performance issues will be fixed in a future version of the library; please bear with us! - In the meantime, if you are using Python 3, you can install a C extension + In the meantime, you can install a C extension that boosts PyOTA's performance significantly (speedups of 60x are common!). To install the extension, run ``pip install pyota[ccurl]``. - **Important:** The extension is not yet compatible with Python 2. - - If you are familiar with Python 2's C API, we'd love to hear from you! - Check the `GitHub issue `_ - for more information. - PyOTA provides two methods for generating addresses: Using the API @@ -60,7 +54,9 @@ method, using the following parameters: (defaults to 1). - If ``None``, the API will generate addresses until it finds one that has not been used (has no transactions associated with it on the - Tangle). It will then return the unused address and discard the rest. + Tangle, and was not spent from). This makes the command safer to use after + a snapshot has been taken. It will then return the unused address and + discard the rest. - ``security_level: int``: Determines the security level of the generated addresses. See `Security Levels <#security-levels>`__ below. diff --git a/iota/api.py b/iota/api.py index 9b12bf79..5f8dda19 100644 --- a/iota/api.py +++ b/iota/api.py @@ -1217,8 +1217,9 @@ def get_new_addresses( inside a loop. If ``None``, this method will progressively generate - addresses and scan the Tangle until it finds one that has no - transactions referencing it. + addresses and scan the Tangle until it finds one that is unused. + This is if no transactions are referencing it and it was not spent + from before. :param int security_level: Number of iterations to use when generating new addresses. diff --git a/iota/commands/extended/get_account_data.py b/iota/commands/extended/get_account_data.py index 7235d10b..46dd8d1d 100644 --- a/iota/commands/extended/get_account_data.py +++ b/iota/commands/extended/get_account_data.py @@ -59,7 +59,7 @@ def _execute(self, request): my_hashes = ft_command(addresses=my_addresses).get('hashes') or [] account_balance = 0 - if my_hashes: + if my_addresses: # Load balances for the addresses that we generated. gb_response = ( GetBalancesCommand(self.adapter)(addresses=my_addresses) diff --git a/iota/commands/extended/get_new_addresses.py b/iota/commands/extended/get_new_addresses.py index fa9d0882..65f3c577 100644 --- a/iota/commands/extended/get_new_addresses.py +++ b/iota/commands/extended/get_new_addresses.py @@ -9,6 +9,8 @@ from iota import Address from iota.commands import FilterCommand, RequestFilter from iota.commands.core.find_transactions import FindTransactionsCommand +from iota.commands.core.were_addresses_spent_from import \ + WereAddressesSpentFromCommand from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed from iota.filters import SecurityLevel, Trytes @@ -58,17 +60,23 @@ def _find_addresses(self, seed, index, count, security_level, checksum): generator = AddressGenerator(seed, security_level, checksum) if count is None: - # Connect to Tangle and find the first address without any - # transactions. + # Connect to Tangle and find the first unused address. for addy in generator.create_iterator(start=index): - # We use addy.address here because FindTransactions does + # We use addy.address here because the commands do # not work on an address with a checksum + response = WereAddressesSpentFromCommand(self.adapter)( + addresses=[addy.address], + ) + if response['states'][0]: + continue + response = FindTransactionsCommand(self.adapter)( addresses=[addy.address], ) + if response.get('hashes'): + continue - if not response.get('hashes'): - return [addy] + return [addy] return generator.get_addresses(start=index, count=count) diff --git a/iota/commands/extended/utils.py b/iota/commands/extended/utils.py index a8d3eb8e..2ab0afd2 100644 --- a/iota/commands/extended/utils.py +++ b/iota/commands/extended/utils.py @@ -9,6 +9,8 @@ from iota.adapter import BaseAdapter from iota.commands.core.find_transactions import FindTransactionsCommand from iota.commands.core.get_trytes import GetTrytesCommand +from iota.commands.core.were_addresses_spent_from import \ + WereAddressesSpentFromCommand from iota.commands.extended import FindTransactionObjectsCommand from iota.commands.extended.get_bundles import GetBundlesCommand from iota.commands.extended.get_latest_inclusion import \ @@ -25,15 +27,17 @@ def iter_used_addresses( ): # type: (...) -> Generator[Tuple[Address, List[TransactionHash]], None, None] """ - Scans the Tangle for used addresses. + Scans the Tangle for used addresses. A used address is an address that + was spent from or has a transaction. This is basically the opposite of invoking ``getNewAddresses`` with - ``stop=None``. + ``count=None``. """ if security_level is None: security_level = AddressGenerator.DEFAULT_SECURITY_LEVEL ft_command = FindTransactionsCommand(adapter) + wasf_command = WereAddressesSpentFromCommand(adapter) for addy in AddressGenerator(seed, security_level).create_iterator(start): ft_response = ft_command(addresses=[addy]) @@ -41,10 +45,15 @@ def iter_used_addresses( if ft_response['hashes']: yield addy, ft_response['hashes'] else: - break + wasf_response = wasf_command(addresses=[addy]) + if wasf_response['states'][0]: + yield addy, [] + else: + break - # Reset the command so that we can call it again. + # Reset the commands so that we can call them again. ft_command.reset() + wasf_command.reset() def get_bundles_from_transaction_hashes( diff --git a/test/commands/extended/get_account_data_test.py b/test/commands/extended/get_account_data_test.py index fd743f75..649ac39f 100644 --- a/test/commands/extended/get_account_data_test.py +++ b/test/commands/extended/get_account_data_test.py @@ -435,3 +435,28 @@ def test_no_transactions(self): 'bundles': [], }, ) + + def test_balance_is_found_for_address_without_transaction(self): + """ + If an address has a balance, no transactions and was spent from, the + balance should still be found and returned. + """ + with mock.patch( + 'iota.commands.extended.get_account_data.iter_used_addresses', + mock.Mock(return_value=[(self.addy1, [])]), + ): + self.adapter.seed_response('getBalances', { + 'balances': [42], + }) + + response = self.command(seed=Seed.random()) + + self.assertDictEqual( + response, + + { + 'addresses': [self.addy1], + 'balance': 42, + 'bundles': [], + }, + ) diff --git a/test/commands/extended/get_inputs_test.py b/test/commands/extended/get_inputs_test.py index de62d9f0..ee3594ab 100644 --- a/test/commands/extended/get_inputs_test.py +++ b/test/commands/extended/get_inputs_test.py @@ -590,12 +590,9 @@ def test_no_stop_threshold_met(self): """ No ``stop`` provided, balance meets ``threshold``. """ - self.adapter.seed_response('getBalances', { - 'balances': [42, 29], - }) - # ``getInputs`` uses ``findTransactions`` to identify unused - # addresses. + # ``getInputs`` uses ``findTransactions`` and + # ``wereAddressesSpentFrom`` to identify unused addresses. # noinspection SpellCheckingInspection self.adapter.seed_response('findTransactions', { 'hashes': [ @@ -620,6 +617,14 @@ def test_no_stop_threshold_met(self): 'hashes': [], }) + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) + + self.adapter.seed_response('getBalances', { + 'balances': [42, 29], + }) + # To keep the unit test nice and speedy, we will mock the address # generator. We already have plenty of unit tests for that # functionality, so we can get away with mocking it here. @@ -686,12 +691,9 @@ def test_no_stop_threshold_zero(self): """ No ``stop`` provided, ``threshold`` is 0. """ - # Note that the first address has a zero balance. - self.adapter.seed_response('getBalances', { - 'balances': [0, 1], - }) - # ``getInputs`` uses ``findTransactions`` to identify unused + # ``getInputs`` uses ``findTransactions`` and + # ``wereAddressesSpentFrom`` to identify unused addresses. # addresses. # noinspection SpellCheckingInspection self.adapter.seed_response('findTransactions', { @@ -717,6 +719,15 @@ def test_no_stop_threshold_zero(self): 'hashes': [], }) + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) + + # Note that the first address has a zero balance. + self.adapter.seed_response('getBalances', { + 'balances': [0, 1], + }) + # To keep the unit test nice and speedy, we will mock the address # generator. We already have plenty of unit tests for that # functionality, so we can get away with mocking it here. @@ -750,12 +761,9 @@ def test_no_stop_no_threshold(self): """ No ``stop`` provided, no ``threshold``. """ - self.adapter.seed_response('getBalances', { - 'balances': [42, 29], - }) - # ``getInputs`` uses ``findTransactions`` to identify unused - # addresses. + # ``getInputs`` uses ``findTransactions`` and + # ``wereAddressesSpentFrom`` to identify unused addresses. # noinspection SpellCheckingInspection self.adapter.seed_response('findTransactions', { 'hashes': [ @@ -780,6 +788,14 @@ def test_no_stop_no_threshold(self): 'hashes': [], }) + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) + + self.adapter.seed_response('getBalances', { + 'balances': [42, 29], + }) + # To keep the unit test nice and speedy, we will mock the address # generator. We already have plenty of unit tests for that # functionality, so we can get away with mocking it here. @@ -818,12 +834,9 @@ def test_start(self): """ Using ``start`` to offset the key range. """ - self.adapter.seed_response('getBalances', { - 'balances': [86], - }) - # ``getInputs`` uses ``findTransactions`` to identify unused - # addresses. + # ``getInputs`` uses ``findTransactions`` and + # ``wereAddressesSpentFrom`` to identify unused addresses. # noinspection SpellCheckingInspection self.adapter.seed_response('findTransactions', { 'hashes': [ @@ -838,6 +851,14 @@ def test_start(self): 'hashes': [], }) + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) + + self.adapter.seed_response('getBalances', { + 'balances': [86], + }) + # To keep the unit test nice and speedy, we will mock the address # generator. We already have plenty of unit tests for that # functionality, so we can get away with mocking it here. @@ -926,11 +947,8 @@ def test_security_level_1_no_stop(self): seed = Seed.random() address = AddressGenerator(seed, security_level=1).get_addresses(0)[0] - self.adapter.seed_response('getBalances', { - 'balances': [86], - }) - # ``getInputs`` uses ``findTransactions`` to identify unused - # addresses. + # ``getInputs`` uses ``findTransactions`` and + # ``wereAddressesSpentFrom`` to identify unused addresses. # noinspection SpellCheckingInspection self.adapter.seed_response('findTransactions', { 'hashes': [ @@ -944,6 +962,14 @@ def test_security_level_1_no_stop(self): 'hashes': [], }) + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) + + self.adapter.seed_response('getBalances', { + 'balances': [86], + }) + response = GetInputsCommand(self.adapter)( seed=seed, securityLevel=1, diff --git a/test/commands/extended/get_new_addresses_test.py b/test/commands/extended/get_new_addresses_test.py index 735aa90e..53730e78 100644 --- a/test/commands/extended/get_new_addresses_test.py +++ b/test/commands/extended/get_new_addresses_test.py @@ -423,20 +423,20 @@ def test_security_level(self): }, ) - def test_get_addresses_online(self): + def test_get_addresses_online_already_spent_from(self): """ - Generate address in online mode (filtering used addresses). + Generate address in online mode (filtering used addresses). Test if an + address that was already spent from will not be returned. """ - # Pretend that ``self.addy1`` has already been used, but not - # ``self.addy2``. - # noinspection SpellCheckingInspection - self.adapter.seed_response('findTransactions', { - 'duration': 18, + # Pretend that ``self.addy1`` has no transactions but already been + # spent from, but ``self.addy2`` is not used. - 'hashes': [ - 'TESTVALUE9DONTUSEINPRODUCTION99999ITQLQN' - 'LPPG9YNAARMKNKYQO9GSCSBIOTGMLJUFLZWSY9999', - ], + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [True], + }) + + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], }) self.adapter.seed_response('findTransactions', { @@ -461,14 +461,73 @@ def test_get_addresses_online(self): self.assertListEqual( self.adapter.requests, - # The command issued two `findTransactions` API requests: one for - # each address generated, until it found an unused address. + # The command issued a `wereAddressesSpentFrom` API request to + # check if the first address was used. Then it called `wereAddressesSpentFrom` + # and `findTransactions` to verify that the second address was + # indeed not used. [ { - 'command': 'findTransactions', + 'command': 'wereAddressesSpentFrom', 'addresses': [self.addy_1], }, + { + 'command': 'wereAddressesSpentFrom', + 'addresses': [self.addy_2], + }, + { + 'command': 'findTransactions', + 'addresses': [self.addy_2], + }, + ], + ) + + def test_get_addresses_online_has_transaction(self): + """ + Generate address in online mode (filtering used addresses). Test if an + address that has a transaction will not be returned. + """ + # Pretend that ``self.addy1`` has a transaction, but + # ``self.addy2`` is not used. + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) + # noinspection SpellCheckingInspection + self.adapter.seed_response('findTransactions', { + 'duration': 18, + 'hashes': [ + 'TESTVALUE9DONTUSEINPRODUCTION99999ITQLQN' + 'LPPG9YNAARMKNKYQO9GSCSBIOTGMLJUFLZWSY9999', + ], + }) + + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) + self.adapter.seed_response('findTransactions', { + 'hashes': [], + }) + + response = self.command(index=0, seed=self.seed) + + # The command determined that ``self.addy1`` was already used, so + # it skipped that one. + self.assertDictEqual(response, {'addresses': [self.addy_2]}) + self.assertListEqual( + self.adapter.requests, + [ + { + 'command': 'wereAddressesSpentFrom', + 'addresses': [self.addy_1], + }, + { + 'command': 'findTransactions', + 'addresses': [self.addy_1], + }, + { + 'command': 'wereAddressesSpentFrom', + 'addresses': [self.addy_2], + }, { 'command': 'findTransactions', 'addresses': [self.addy_2], diff --git a/test/commands/extended/get_transfers_test.py b/test/commands/extended/get_transfers_test.py index ff2f4f67..4b64d707 100644 --- a/test/commands/extended/get_transfers_test.py +++ b/test/commands/extended/get_transfers_test.py @@ -372,7 +372,7 @@ def create_generator(ag, start, step=1): }, ) - # The second address is unused. + # The second address is unused. It has no transactions and was not spent from. self.adapter.seed_response( 'findTransactions', @@ -381,6 +381,12 @@ def create_generator(ag, start, step=1): 'hashes': [], }, ) + self.adapter.seed_response( + 'wereAddressesSpentFrom', + { + 'states': [False], + }, + ) self.adapter.seed_response( 'getTrytes', @@ -461,6 +467,12 @@ def create_generator(ag, start, step=1): 'hashes': [], }, ) + self.adapter.seed_response( + 'wereAddressesSpentFrom', + { + 'states': [False], + }, + ) with mock.patch( 'iota.crypto.addresses.AddressGenerator.create_iterator', @@ -495,7 +507,7 @@ def create_generator(ag, start, step=1): }, ) - # The second address is unused. + # The second address is unused. It has no transactions and was not spent from. self.adapter.seed_response( 'findTransactions', @@ -505,6 +517,13 @@ def create_generator(ag, start, step=1): }, ) + self.adapter.seed_response( + 'wereAddressesSpentFrom', + { + 'states': [True], + }, + ) + self.adapter.seed_response( 'getTrytes', diff --git a/test/commands/extended/prepare_transfer_test.py b/test/commands/extended/prepare_transfer_test.py index 91f35446..35e0f9df 100644 --- a/test/commands/extended/prepare_transfer_test.py +++ b/test/commands/extended/prepare_transfer_test.py @@ -1307,11 +1307,15 @@ def mock_get_balances_execute(adapter, request): # testing for several security levels for security_level in SECURITY_LEVELS_TO_TEST: - # get_new_addresses uses `find_transactions` internaly. + # get_new_addresses uses `find_transactions` and + # `were_addresses_spent_from` internally. # The following means requested address is considered unused self.adapter.seed_response('findTransactions', { 'hashes': [], }) + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) self.command.reset() with mock.patch( @@ -1377,8 +1381,8 @@ def mock_get_balances_execute(adapter, request): # testing several security levels for security_level in SECURITY_LEVELS_TO_TEST: - # get_inputs use iter_used_addresses and findTransactions. - # until address without tx found + # get_inputs uses iter_used_addresses, findTransactions, + # and wereAddressesSpentFrom until an unused address is found. self.adapter.seed_response('findTransactions', { 'hashes': [ TransactionHash( @@ -1390,8 +1394,16 @@ def mock_get_balances_execute(adapter, request): self.adapter.seed_response('findTransactions', { 'hashes': [], }) + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) - # get_new_addresses uses `find_transactions` internaly. + # get_new_addresses uses `find_transactions`, `get_balances` and + # `were_addresses_spent_from` internally. + + self.adapter.seed_response('wereAddressesSpentFrom', { + 'states': [False], + }) self.adapter.seed_response('findTransactions', { 'hashes': [], }) From 7349985524cafa96b13559470657161453ffdc8e Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Fri, 29 Nov 2019 17:04:27 +0100 Subject: [PATCH 15/20] docs: improve `Generating Addresses` page - add figure depicting address generation - explain address generation algorithm - document AddressGenerator - general improvements in text --- docs/addresses.rst | 124 +++++++++++++++++++++++++++++------- docs/images/address_gen.svg | 3 + docs/index.rst | 2 +- iota/api.py | 15 ++++- iota/crypto/addresses.py | 53 +++++++++------ 5 files changed, 152 insertions(+), 45 deletions(-) create mode 100644 docs/images/address_gen.svg diff --git a/docs/addresses.rst b/docs/addresses.rst index 01fa0fc8..9bba032e 100644 --- a/docs/addresses.rst +++ b/docs/addresses.rst @@ -5,9 +5,10 @@ In IOTA, addresses are generated deterministically from seeds. This ensures that your account can be accessed from any location, as long as you have the seed. -Note that this also means that anyone with access to your seed can spend -your IOTAs! Treat your seed(s) the same as you would the password for -any other financial service. +.. warning:: + Note that this also means that anyone with access to your seed can spend + your iotas! Treat your seed(s) the same as you would the password for + any other financial service. .. note:: @@ -22,6 +23,41 @@ any other financial service. To install the extension, run ``pip install pyota[ccurl]``. +Algorithm +--------- + +.. figure:: images/address_gen.svg + :scale: 100 % + :alt: Process of address generation in IOTA. + + Deriving addresses from a seed. + +The following process takes place when you generate addresses in IOTA: + +1. First, a sub-seed is derived from your seed by adding ``index`` to it, + and hashing it once with the `Kerl`_ hash function. +2. Then the sub-seed is absorbed and squeezed in a `sponge function`_ 27 times + for each security level. The result is a private key that varies in length + depending on security level. + + .. note:: + A private key with ``security_level = 1`` consists of 2187 trytes, which is + exactly 27 x 81 trytes. As the security level increases, so does the length + of the private key: 2 x 2187 trytes for ``security_level = 2``, and 3 x 2187 + trytes for ``security_level = 3``. + +3. A private key is split into 81-tryte segments, and these segments are hashed + 26 times. A group of 27 hashed segments is called a key fragment. Observe, + that a private key has one key fragment for each security level. +4. Each key fragment is hashed once more to generate key digests, that are + combined and hashed once more to get the 81-tryte address. + + .. note:: + An address is the public key pair of the corresponding private key. When + you spend iotas from an address, you need to sign the transaction with a + key digest that was generated from the address's corresponing private key. + This way you prove that you own the funds on that address. + PyOTA provides two methods for generating addresses: Using the API @@ -35,6 +71,7 @@ Using the API # Generate 5 addresses, starting with index 0. gna_result = api.get_new_addresses(count=5) + # Result is a dict that contains a list of addresses. addresses = gna_result['addresses'] # Generate 1 address, starting with index 42: @@ -45,26 +82,55 @@ Using the API gna_result = api.get_new_addresses(index=86, count=None) addresses = gna_result['addresses'] -To generate addresses using the API, invoke its ``get_new_addresses`` +To generate addresses using the API, invoke its :py:meth:`iota.Iota.get_new_addresses` method, using the following parameters: - ``index: int``: The starting index (defaults to 0). This can be used to skip over addresses that have already been generated. - ``count: Optional[int]``: The number of addresses to generate (defaults to 1). -- If ``None``, the API will generate addresses until it finds one that - has not been used (has no transactions associated with it on the - Tangle, and was not spent from). This makes the command safer to use after - a snapshot has been taken. It will then return the unused address and - discard the rest. + + - If ``None``, the API will generate addresses until it finds one that + has not been used (has no transactions associated with it on the + Tangle and was never spent from). It will then return the unused address + and discard the rest. - ``security_level: int``: Determines the security level of the generated addresses. See `Security Levels <#security-levels>`__ below. -``get_new_addresses`` returns a dict with the following items: +Depending on the ``count`` parameter, :py:meth:`Iota.get_new_addresses` can be +operated in two modes. + +Offline mode +~~~~~~~~~~~~ -- ``addresses: List[Address]``: The generated address(es). Note that - this value is always a list, even if only one address was generated. + When ``count`` is greater than 0, the API generates ``count`` number of + addresses starting from ``index``. It does not check the Tangle if + addresses were used or spent from before. + +Online mode +~~~~~~~~~~~ + + When ``count`` is ``None``, the API starts generating addresses starting + from ``index``. Then, for each generated address, it checks the Tangle + if the address has any transactions associated with it, or if the address + was ever spent from. If both of the former checks return "no", address + generation stops and the address is returned (a new address is found). + +.. warning:: + Take care when using the online mode after a snapshot. Transactions referencing + a generated address may have been pruned from a node's ledger, therefore the + API could return an already-used address as "new" (note: The snapshot has + no effect on the "was ever spent from" check). + + To make your application more robust to handle snapshots, it is recommended + that you keep a local database with at least the indices of your used addresses. + After a snapshot, you could specify ``index`` parameter as the last + index in your local used addresses database, and keep on generating truly + new addresses. + + PyOTA is planned to receive the `account module`_ in the future, that makes + the library stateful and hence would solve the issue mentioned above. Using AddressGenerator ---------------------- @@ -87,7 +153,7 @@ Using AddressGenerator ... If you want more control over how addresses are generated, you can use -the ``AddressGenerator`` class. +:py:class:`iota.crypto.addresses.AddressGenerator`. ``AddressGenerator`` can create iterators, allowing your application to generate addresses as needed, instead of having to generate lots of @@ -97,17 +163,23 @@ You can also specify an optional ``step`` parameter, which allows you to skip over multiple addresses between iterations... or even iterate over addresses in reverse order! -``AddressGenerator`` provides two methods: +AddressGenerator +~~~~~~~~~~~~~~~~ -- ``get_addresses: (int, int, int) -> List[Address]``: Returns a list - of addresses. This is the same method that the ``get_new_addresses`` - API command uses internally. -- ``create_iterator: (int, int) -> Generator[Address]``: Returns an - iterator that will create addresses endlessly. Use this if you have a - feature that needs to generate addresses "on demand". +.. autoclass:: iota.crypto.addresses.AddressGenerator + +**get_addresses** +^^^^^^^^^^^^^^^^^ + +.. automethod:: iota.crypto.addresses.AddressGenerator.get_addresses + +**create_iterator** +^^^^^^^^^^^^^^^^^^^ + +.. automethod:: iota.crypto.addresses.AddressGenerator.create_iterator Security Levels -=============== +--------------- .. code:: python @@ -120,8 +192,10 @@ Security Levels ) If desired, you may change the number of iterations that -``AddressGenerator`` uses internally when generating new addresses, by -specifying a different ``security_level`` when creating a new instance. +:py:class:`iota.crypto.addresses.AddressGenerator` or +:py:class:`iota.Iota.get_new_addresses` uses internally when generating new +addresses, by specifying a different ``security_level`` when creating a new +instance. ``security_level`` should be between 1 and 3, inclusive. Values outside this range are not supported by the IOTA protocol. @@ -134,3 +208,7 @@ Use the following guide when deciding which security level to use: security. - ``security_level=3``: Most secure; results in longer signatures in transactions. + +.. _Kerl: https://github.com/iotaledger/kerl +.. _sponge function: https://keccak.team/sponge_duplex.html +.. _account module: https://docs.iota.org/docs/client-libraries/0.1/account-module/introduction/overview \ No newline at end of file diff --git a/docs/images/address_gen.svg b/docs/images/address_gen.svg new file mode 100644 index 00000000..de979ccd --- /dev/null +++ b/docs/images/address_gen.svg @@ -0,0 +1,3 @@ + + +
KEY FRAGMENTS
[Not supported by viewer]
PRIVATE KEYS
[Not supported by viewer]
ADDRESSES
[Not supported by viewer]
SUB-SEEDS
[Not supported by viewer]
Private Key 0
[Not supported by viewer]
Private Key 1
[Not supported by viewer]
Private Key ...N
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Address 0
[Not supported by viewer]
Address 1
[Not supported by viewer]
Address ...N
[Not supported by viewer]
Private Key 0
[Not supported by viewer]
Private Key 1
[Not supported by viewer]
Private Key ...N
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Address 0
[Not supported by viewer]
Address 1
[Not supported by viewer]
Address ...N
[Not supported by viewer]
SECURITY LEVEL 1
SECURITY LEVEL 1
SECURITY LEVEL 2
SECURITY LEVEL 2
SECURITY LEVEL 3
SECURITY LEVEL 3
Sub-seed is hashed from the Seed based on Index.
[Not supported by viewer]
Sub-seed is hashed into Private Key.
The length of the Private Key depends on the Security Level.
[Not supported by viewer]
Hashed into 27 Key Fragments, that are used during the signing process.
[Not supported by viewer]
Hashed into Address,
that is the Public Key pair.
[Not supported by viewer]
Index 0...N
[Not supported by viewer]
For a given Security Level (SL=1)
<i>For a given <b>Security Level </b>(SL=1)</i>
SEED
[Not supported by viewer]
Sub-seed 0
[Not supported by viewer]
Sub-seed 1
[Not supported by viewer]
Sub-seed ...N
[Not supported by viewer]
Seed
[Not supported by viewer]
Private Key 0
[Not supported by viewer]
Private Key 1
[Not supported by viewer]
Private Key ...N
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Key Fragments
[Not supported by viewer]
Address 0
[Not supported by viewer]
Address 1
[Not supported by viewer]
Address ...N
[Not supported by viewer]
\ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 339eef01..a8432a97 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,10 +6,10 @@ basic_concepts types adapters - addresses api core_api extended_api + addresses multisig .. include:: ../README.rst diff --git a/iota/api.py b/iota/api.py index 5f8dda19..7c43c9f5 100644 --- a/iota/api.py +++ b/iota/api.py @@ -1217,9 +1217,18 @@ def get_new_addresses( inside a loop. If ``None``, this method will progressively generate - addresses and scan the Tangle until it finds one that is unused. - This is if no transactions are referencing it and it was not spent - from before. + addresses and scan the Tangle until it finds one that has no + transactions referencing it and was never spent from. + + .. note:: + A snapshot removes transactions from the Tangle. As a + consequence, after a snapshot, it may happen that when ``count`` + is ``None``, this API call returns a "new" address that used to + have transactions before the snapshot. + As a workaround, you can save your used addresses and their + ``key_index`` attribute in a local database. Use the + ``index`` parameter to tell the API from where to start + generating and checking new addresses. :param int security_level: Number of iterations to use when generating new addresses. diff --git a/iota/crypto/addresses.py b/iota/crypto/addresses.py index 8c335eb4..8425277d 100644 --- a/iota/crypto/addresses.py +++ b/iota/crypto/addresses.py @@ -19,13 +19,14 @@ class AddressGenerator(Iterable[Address]): """ Generates new addresses using a standard algorithm. - Note: This class does not check if addresses have already been used; - if you want to exclude used addresses, invoke - :py:meth:`iota.api.IotaApi.get_new_addresses` instead. + .. note:: + This class does not check if addresses have already been used; + if you want to exclude used addresses, invoke + :py:meth:`iota.Iota.get_new_addresses` instead. - Note also that :py:meth:`iota.api.IotaApi.get_new_addresses` uses - ``AddressGenerator`` internally, so you get the best of both worlds - when you use the API (: + Note also that :py:meth:`iota.Iota.get_new_addresses` uses + ``AddressGenerator`` internally, so you get the best of both worlds + when you use the API (: :param TrytesCompatible seed: The seed to derive addresses from. @@ -44,6 +45,8 @@ class AddressGenerator(Iterable[Address]): :param bool checksum: Whether to generate address with or without checksum. + + :returns: :py:class:`iota.crypto.addresses.AddressGenerator` object. """ DEFAULT_SECURITY_LEVEL = 2 """ @@ -91,19 +94,19 @@ def get_addresses(self, start, count=1, step=1): :py:meth:`create_iterator` and sharing the resulting generator object instead. - Warning: This method may take awhile to run if the starting - index and/or the number of requested addresses is a large - number! + .. warning:: + This method may take awhile to run if the starting + index and/or the number of requested addresses is a large number! - :param start: + :param int start: Starting index. Must be >= 0. - :param count: + :param int count: Number of addresses to generate. Must be > 0. - :param step: + :param int step: Number of indexes to advance after each address. This may be any non-zero (positive or negative) integer. @@ -115,6 +118,10 @@ def get_addresses(self, start, count=1, step=1): The returned list will contain ``count`` addresses, except when ``step * count < start`` (only applies when ``step`` is negative). + + :raises ValueError: + - if ``count`` is lower than 1. + - if ``step`` is zero. """ if count < 1: raise with_context( @@ -157,17 +164,27 @@ def create_iterator(self, start=0, step=1): Creates an iterator that can be used to progressively generate new addresses. - :param start: + Returns an iterator that will create addresses endlessly. + Use this if you have a feature that needs to generate addresses + “on demand”. + + :param int start: Starting index. - Warning: This method may take awhile to reset if ``start`` - is a large number! + .. warning:: + This method may take awhile to reset if ``start`` is a large + number! - :param step: + :param int step: Number of indexes to advance after each address. - Warning: The generator may take awhile to advance between - iterations if ``step`` is a large number! + .. warning:: + The generator may take awhile to advance between + iterations if ``step`` is a large number! + + :return: + ``Generator[Address, None, None]`` object that you can iterate to + generate addresses. """ key_iterator = ( KeyGenerator(self.seed).create_iterator( From c8508cf9a09fe767ffa7d43759b5f6a90cfd098d Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Tue, 3 Dec 2019 17:23:08 +0100 Subject: [PATCH 16/20] docs: improve `Adapters and Wrappers` page - add more documentation on adapter classes - add examples on debugging http requests to file or console - remove sandbox adapter section - restructure RoutingWrapper section --- docs/adapters.rst | 269 +++++++++++++++++++-------------------- docs/conf.py | 4 +- iota/adapter/__init__.py | 73 ++++++++++- iota/adapter/wrappers.py | 55 ++++++-- 4 files changed, 248 insertions(+), 153 deletions(-) diff --git a/docs/adapters.rst b/docs/adapters.rst index 22ee2ade..83940d07 100644 --- a/docs/adapters.rst +++ b/docs/adapters.rst @@ -1,24 +1,45 @@ Adapters and Wrappers ===================== +.. py:currentmodule:: iota -The ``Iota`` class defines the API methods that are available for +The :py:class:`Iota` class defines the API methods that are available for interacting with the node, but it delegates the actual interaction to -another set of classes: Adapters and Wrappers. +another set of classes: `Adapters <#adapters>`__ and `Wrappers <#wrappers>`__. + +The API instance's methods contain the logic and handle PyOTA-specific types, +construct and translate objects, while the API instance's adapter deals with +the networking, communicating with a node. + +You can choose and configure the available adapters to be used with the API: + + - HttpAdapter, + - SandboxAdapter, + - MockAdapter. AdapterSpec ----------- + In a few places in the PyOTA codebase, you may see references to a meta-type called ``AdapterSpec``. -``AdapterSpec`` is a placeholder that means "URI or adapter instance". +.. automodule:: iota.adapter + :special-members: AdapterSpec + +.. py:currentmodule:: iota + +For example, when creating an :py:class:`Iota` object, the first argument +of :py:meth:`Iota.__init__` is an ``AdapterSpec``. This means that you can +initialize an :py:class:`Iota` object using either a node URI, or an adapter +instance: + +- Node URI:: + + api = Iota('http://localhost:14265') -For example, the first argument of ``Iota.__init__`` is an -``AdapterSpec``. This means that you can initialize an ``Iota`` object -using either a node URI, or an adapter instance: +- Adapter instance:: -- Node URI: ``Iota('http://localhost:14265')`` -- Adapter instance: ``Iota(HttpAdapter('http://localhost:14265'))`` + api = Iota(HttpAdapter('http://localhost:14265')) Adapters -------- @@ -33,113 +54,149 @@ HttpAdapter .. code:: python - from iota import Iota - from iota.adapter import HttpAdapter + from iota import Iota, HttpAdapter # Use HTTP: api = Iota('http://localhost:14265') api = Iota(HttpAdapter('http://localhost:14265')) # Use HTTPS: - api = Iota('https://service.iotasupport.com:14265') - api = Iota(HttpAdapter('https://service.iotasupport.com:14265')) + api = Iota('https://nodes.thetangle.org:443') + api = Iota(HttpAdapter('https://nodes.thetangle.org:443')) # Use HTTPS with basic authentication and 60 seconds timeout: api = Iota( HttpAdapter( - 'https://service.iotasupport.com:14265', + 'https://nodes.thetangle.org:443', authentication=('myusername', 'mypassword'), timeout=60)) -``HttpAdapter`` uses the HTTP protocol to send requests to the node. +.. autoclass:: HttpAdapter -To configure an ``Iota`` instance to use ``HttpAdapter``, specify an -``http://`` or ``https://`` URI, or provide an ``HttpAdapter`` instance. +To configure an :py:class:`Iota` instance to use :py:class:`HttpAdapter`, +specify an ``http://`` or ``https://`` URI, or provide an +:py:class:`HttpAdapter` instance. -The ``HttpAdapter`` raises a ``BadApiResponse`` exception if the server +The :py:class:`HttpAdapter` raises a ``BadApiResponse`` exception if the server sends back an error response (due to invalid request parameters, for example). Debugging HTTP Requests ^^^^^^^^^^^^^^^^^^^^^^^ - -.. code:: python - - from logging import getLogger - - from iota import Iota - - api = Iota('http://localhost:14265') - api.adapter.set_logger(getLogger(__name__)) - To see all HTTP requests and responses as they happen, attach a ``logging.Logger`` instance to the adapter via its ``set_logger`` method. -Any time the ``HttpAdapter`` sends a request or receives a response, it +Any time the :py:class:`HttpAdapter` sends a request or receives a response, it will first generate a log message. Note: if the response is an error -response (e.g., due to invalid request parameters), the ``HttpAdapter`` +response (e.g., due to invalid request parameters), the :py:class:`HttpAdapter` will log the request before raising ``BadApiResponse``. .. note:: - ``HttpAdapter`` generates log messages with ``DEBUG`` level, so make sure that your logger's ``level`` attribute is set low enough that it doesn't filter these messages! + :py:class:`HttpAdapter` generates log messages with ``DEBUG`` level, so make + sure that your logger's ``level`` attribute is set low enough that it + doesn't filter these messages! -SandboxAdapter -~~~~~~~~~~~~~~ +**Logging to console with default format** .. code:: python + from logging import getLogger, basicConfig, DEBUG from iota import Iota - from iota.adapter.sandbox import SandboxAdapter - api =\ - Iota( - SandboxAdapter( - uri = 'https://sandbox.iotatoken.com/api/v1/', - auth_token = 'demo7982-be4a-4afa-830e-7859929d892c', - ), - ) + api = Iota("https://nodes.thetangle.org:443") -The ``SandboxAdapter`` is a specialized ``HttpAdapter`` that sends -authenticated requests to sandbox nodes. + # Sets the logging level for the root logger (and for its handlers) + basicConfig(level=DEBUG) -.. note:: + # Get a new logger derived from the root logger + logger = getLogger(__name__) - See `Sandbox `_ Documentation for more information about sandbox nodes. + # Attach the logger to the adapter + api.adapter.set_logger(logger) -Sandbox nodes process certain commands asynchronously. When -``SandboxAdapter`` determines that a request is processed -asynchronously, it will block, then poll the node periodically until it -receives a response. + # Execute a command that sends request to the node + api.get_node_info() -The result is that ``SandboxAdapter`` abstracts away the sandbox node's -asynchronous functionality so that your API client behaves exactly the -same as if it were connecting to a non-sandbox node. + # Log messages should be printed to console -To create a ``SandboxAdapter``, you must provide the URI of the sandbox -node and the auth token that you received from the node maintainer. Note -that ``SandboxAdapter`` only works with ``http://`` and ``https://`` -URIs. +**Logging to a file with custom format** -You may also specify the polling interval (defaults to 15 seconds) and -the number of polls before giving up on an asynchronous job (defaults to -8 times). +.. code:: python -.. note:: + from logging import getLogger, DEBUG, FileHandler, Formatter + from iota import Iota + + # Create a custom logger + logger = getLogger(__name__) + + # Set logging level to DEBUG + logger.setLevel(DEBUG) + + # Create handler to write to a log file + f_handler = FileHandler(filename='pyota.log',mode='a') + f_handler.setLevel(DEBUG) + + # Create formatter and add it to handler + f_format = Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + f_handler.setFormatter(f_format) + + # Add handler to the logger + logger.addHandler(f_handler) + + # Create API instance + api = Iota("https://nodes.thetangle.org:443") + + # Add logger to the adapter of the API instance + api.adapter.set_logger(logger) - For parity with the other adapters, ``SandboxAdapter`` blocks until it receives a response from the node. + # Sends a request to the node + api.get_node_info() - If you do not want ``SandboxAdapter`` to block the main thread, it is recommended that you execute it in a separate thread or process. + # Open 'pyota.log' file and observe the logs +**Logging to console with custom format** + +.. code:: python + + from logging import getLogger, DEBUG, StreamHandler, Formatter + from iota import Iota + + # Create a custom logger + logger = getLogger(__name__) + + # Set logging level to DEBUG + logger.setLevel(DEBUG) + + # Create handler to write to sys.stderr + s_handler = StreamHandler() + s_handler.setLevel(DEBUG) + + # Create formatter and add it to handler + s_format = Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + s_handler.setFormatter(s_format) + + # Add handler to the logger + logger.addHandler(s_handler) + + # Create API instance + api = Iota("https://nodes.thetangle.org:443") + + # Add logger to the adapter of the API instance + api.adapter.set_logger(logger) + + # Sends a request to the node + api.get_node_info() + + # Observe log messages in console MockAdapter ~~~~~~~~~~~ .. code:: python - from iota import Iota - from iota.adapter import MockAdapter + from iota import Iota, MockAdapter # Inject a mock adapter. api = Iota('mock://') @@ -154,35 +211,14 @@ MockAdapter print(api.get_node_info()) # {'message': 'Hello, IOTA!'} print(api.get_node_info()) # raises BadApiResponse exception -``MockAdapter`` is used to simulate the behavior of an adapter without -actually sending any requests to the node. +.. autoclass:: MockAdapter -This is particularly useful in unit and functional tests where you want -to verify that your code works correctly in specific scenarios, without -having to engineer your own subtangle. +To use :py:class:`MockAdapter`, you must first seed the responses that you want +it to return by calling its :py:meth:`MockAdapter.seed_response` method. -To configure an ``Iota`` instance to use ``MockAdapter``, specify -``mock://`` as the node URI, or provide a ``MockAdapter`` instance. - -To use ``MockAdapter``, you must first seed the responses that you want -it to return by calling its ``seed_response`` method. - -``seed_response`` takes two parameters: - -- ``command: Text``: The name of the command. Note that this is the - camelCase version of the command name (e.g., ``getNodeInfo``, not - ``get_node_info``). -- ``response: dict``: The response that the adapter will return. - -You can seed multiple responses for the same command; the -``MockAdapter`` maintains a queue for each command internally, and it -will pop a response off of the corresponding queue each time it -processes a request. - -Note that you have to call ``seed_response`` once for each request you -expect it to process. If ``MockAdapter`` does not have a seeded response -for a particular command, it will raise a ``BadApiResponse`` exception -(simulates a 404 response). +**seed_response** +^^^^^^^^^^^^^^^^^ +.. automethod:: MockAdapter.seed_response Wrappers -------- @@ -192,53 +228,8 @@ otherwise modify the behavior of adapters. RoutingWrapper ~~~~~~~~~~~~~~ +.. autoclass:: iota.adapter.wrappers.RoutingWrapper -.. code:: python - - from iota import Iota - from iota.adapter.wrappers import RoutingWrapper - - api =\ - Iota( - # Send PoW requests to local node. - # All other requests go to light wallet node. - RoutingWrapper('https://service.iotasupport.com:14265') - .add_route('attachToTangle', 'http://localhost:14265') - .add_route('interruptAttachingToTangle', 'http://localhost:14265') - ) - -``RoutingWrapper`` allows you to route API requests to different nodes -depending on the command name. - -For example, you could use this wrapper to direct all PoW requests to a -local node, while sending the other requests to a light wallet node. - -.. note:: - - A common use case for ``RoutingWrapper`` is to perform proof-of-work on - a specific (local) node, but let all other requests go to another node. - Take care when you use ``RoutingWrapper`` adapter and ``local_pow`` - parameter together in an API instance, because the behavior might not - be obvious. - - ``local_pow`` tells the API to perform proof-of-work (``attach_to_tangle``) - without relying on an actual node. It does this by calling an extension - package `PyOTA-PoW `_ that does the - job. In PyOTA, this means the request doesn't reach the adapter, it - is redirected before. - As a consequence, ``local_pow`` has precedence over the route that is - defined in ``RoutingWrapper``. - -``RoutingWrapper`` must be initialized with a default URI/adapter. This -is the adapter that will be used for any command that doesn't have a -route associated with it. - -Once you've initialized the ``RoutingWrapper``, invoke its ``add_route`` -method to specify a different adapter to use for a particular command. - -``add_route`` requires two arguments: - -- ``command: Text``: The name of the command. Note that this is the - camelCase version of the command name (e.g., ``getNodeInfo``, not - ``get_node_info``). -- ``adapter: AdapterSpec``: The adapter or URI to send this request to. +**add_route** +^^^^^^^^^^^^^ +.. automethod:: iota.adapter.wrappers.RoutingWrapper.add_route \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 9a66bd0d..782a2b38 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,8 +47,8 @@ # General information about the project. project = 'PyOTA' -copyright = '2017, Phoenix Zerin' -author = 'Phoenix Zerin' +copyright = '2019, Phoenix Zerin & Levente Pap' +author = 'Phoenix Zerin, Levente Pap' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/iota/adapter/__init__.py b/iota/adapter/__init__.py index 48457fb8..45862008 100644 --- a/iota/adapter/__init__.py +++ b/iota/adapter/__init__.py @@ -44,6 +44,12 @@ # Custom types for type hints and docstrings. AdapterSpec = Union[Text, 'BaseAdapter'] +""" +Placeholder that means “URI or adapter instance”. + +Will be resolved to a correctly-configured adapter instance +upon API instance creation. +""" # Load SplitResult for IDE type hinting and autocompletion. if PY2: @@ -144,7 +150,7 @@ class BaseAdapter(object): """ Interface for IOTA API adapters. - Adapters make it easy to customize the way an StrictIota instance + Adapters make it easy to customize the way an API instance communicates with a node. """ supported_protocols = () # type: Tuple[Text] @@ -222,9 +228,32 @@ def set_local_pow(self, local_pow): class HttpAdapter(BaseAdapter): """ - Sends standard HTTP requests. + Sends standard HTTP(S) requests to the node. + + :param AdapterSpec uri: + URI or adapter instance. + + If ``uri`` is a ``text_type``, it is parsed to extract ``scheme``, + ``hostname`` and ``port``. + + :param Optional[int] timeout: + Connection timeout in seconds. + + :param Optional[Tuple(Text,Text)] authentication: + Credetentials for basic authentication with the node. + + :return: + :py:class:`HttpAdapter` object. + + :raises InvalidUri: + - if protocol is unsupported. + - if hostname is empty. + - if non-numeric port is supplied. """ supported_protocols = ('http', 'https',) + """ + Protocols supported by this adapter. + """ DEFAULT_HEADERS = { 'Content-type': 'application/json', @@ -469,11 +498,35 @@ def _interpret_response(self, response, payload, expected_status): class MockAdapter(BaseAdapter): """ - An mock adapter used for simulating API responses. + A mock adapter used for simulating API responses without actually sending + any requests to the node. + + This is particularly useful in unit and functional tests where you want + to verify that your code works correctly in specific scenarios, without + having to engineer your own subtangle. To use this adapter, you must first "seed" the responses that the adapter should return for each request. The adapter will then return the appropriate seeded response each time it "sends" a request. + + :param ``None``: + To construct a ``MockAdapter``, you don't need to supply any arguments. + + :return: + :py:class:`MockAdapter` object. + + To configure an :py:class:`Iota` instance to use :py:class:`MockAdapter`, + specify ``mock://`` as the node URI, or provide a :py:class:`MockAdapter` + instance. + + Example usage:: + + from iota import Iota, MockAdapter + + # Create API with a mock adapter. + api = Iota('mock://') + api = Iota(MockAdapter()) + """ supported_protocols = ('mock',) @@ -501,7 +554,19 @@ def seed_response(self, command, response): put them into a FIFO queue. When a request comes in, the adapter will pop the corresponding response off of the queue. - Example: + Note that you have to call :py:meth:`seed_response` once for each + request you expect it to process. If :py:class:`MockAdapter` does not + have a seeded response for a particular command, it will raise a + ``BadApiResponse`` exception (simulates a 404 response). + + :param Text command: + The name of the command. Note that this is the camelCase version + of the command name (e.g., ``getNodeInfo``, not ``get_node_info``). + + :param dict response: + The response that the adapter will return. + + Example usage: .. code-block:: python diff --git a/iota/adapter/wrappers.py b/iota/adapter/wrappers.py index a7170166..c4f4b6cb 100644 --- a/iota/adapter/wrappers.py +++ b/iota/adapter/wrappers.py @@ -44,21 +44,53 @@ def send_request(self, payload, **kwargs): class RoutingWrapper(BaseWrapper): """ - Routes commands to different nodes. + Routes commands (API requests) to different nodes depending on the command + name. This allows you to, for example, send POW requests to a local node, while routing all other requests to a remote one. - Example: + Once you've initialized the :py:class:`RoutingWrapper`, invoke its + :py:meth:`add_route` method to specify a different adapter to use for a + particular command. + + :param AdapterSpec default_adapter: + RoutingWrapper must be initialized with a default URI/adapter. + This is the adapter that will be used for any command that doesn’t have + a route associated with it. + + :return: + :py:class:`RoutingWrapper` object. + + Example usage: .. code-block:: python - # Route POW to localhost, everything else to 12.34.56.78. - iota = Iota( - RoutingWrapper('http://12.34.56.78:14265') + from iota import Iota + from iota.adapter.wrappers import RoutingWrapper + + # Route POW to localhost, everything else to 'https://nodes.thetangle.org:443'. + api = Iota( + RoutingWrapper('https://nodes.thetangle.org:443.'') .add_route('attachToTangle', 'http://localhost:14265') .add_route('interruptAttachingToTangle', 'http://localhost:14265') ) + + .. note:: + + A common use case for :py:class:`RoutingWrapper` is to perform + proof-of-work on a specific (local) node, but let all other requests go to another node. + Take care when you use :py:class:`RoutingWrapper` adapter and ``local_pow`` + parameter together in an API instance (see :py:class:`iota.Iota`), because the behavior might not + be obvious. + + ``local_pow`` tells the API to perform proof-of-work (:py:meth:`iota.Iota.attach_to_tangle`) + without relying on an actual node. It does this by calling an extension + package `PyOTA-PoW `_ that does the + job. In PyOTA, this means the request doesn't reach the adapter, it + is redirected before. + As a consequence, ``local_pow`` has precedence over the route that is + defined in :py:class:`RoutingWrapper`. """ def __init__(self, default_adapter): @@ -80,11 +112,18 @@ def add_route(self, command, adapter): """ Adds a route to the wrapper. - :param command: - The name of the command to route (e.g., "attachToTangle"). + :param Text command: + The name of the command. Note that this is the camelCase version of + the command name (e.g., ``attachToTangle``, not ``attach_to_tangle``). - :param adapter: + :param AdapterSpec adapter: The adapter object or URI to route requests to. + + :return: + The :py:class:`RoutingWrapper` object it was called on. Useful for + chaining the operation of adding routes in code. + + See :py:class:`RoutingWrapper` for example usage. """ if not isinstance(adapter, BaseAdapter): try: From d33fa2874c16249c26de124bf0b96014c467ae76 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Mon, 9 Dec 2019 16:35:59 +0100 Subject: [PATCH 17/20] Refactor commands for API classes - Removed auto command discovery in API class, as it was only used in tests. - Modified test cases. - Removed custom __getattr__() from API class. --- iota/api.py | 75 +------------------ iota/commands/__init__.py | 72 +----------------- iota/multisig/api.py | 2 - test/__init__.py | 2 + test/api_test.py | 21 ------ test/commands/core/add_neighbors_test.py | 23 ++++-- test/commands/core/attach_to_tangle_test.py | 23 ++++-- .../core/broadcast_transactions_test.py | 23 ++++-- test/commands/core/check_consistency_test.py | 22 +++++- test/commands/core/find_transactions_test.py | 22 +++++- test/commands/core/get_balances_test.py | 22 +++++- .../core/get_inclusion_states_test.py | 22 +++++- .../core/get_missing_transactions_test.py | 22 +++++- test/commands/core/get_neighbors_test.py | 22 +++++- .../core/get_node_api_configuration_test.py | 22 +++++- test/commands/core/get_node_info_test.py | 22 +++++- test/commands/core/get_tips_test.py | 22 +++++- .../core/get_transactions_to_approve_test.py | 26 +++++-- test/commands/core/get_trytes_test.py | 22 +++++- .../interrupt_attaching_to_tangle_test.py | 22 +++++- test/commands/core/remove_neighbors_test.py | 22 +++++- test/commands/core/store_transactions_test.py | 22 +++++- .../core/were_addresses_spent_from_test.py | 22 +++++- .../extended/broadcast_and_store_test.py | 22 +++++- .../extended/broadcast_bundle_test.py | 28 ++++--- .../extended/find_transaction_objects.py | 24 ++++-- .../extended/get_account_data_test.py | 22 +++++- test/commands/extended/get_bundles_test.py | 24 ++++-- test/commands/extended/get_inputs_test.py | 22 +++++- .../extended/get_latest_inclusion_test.py | 22 +++++- .../extended/get_new_addresses_test.py | 22 +++++- .../extended/get_transaction_objects_test.py | 24 ++++-- test/commands/extended/get_transfers_test.py | 21 +++++- test/commands/extended/is_promotable_test.py | 22 +++++- .../commands/extended/is_reattachable_test.py | 22 +++++- .../extended/prepare_transfer_test.py | 22 +++++- .../extended/promote_transaction_test.py | 24 ++++-- test/commands/extended/replay_bundle_test.py | 24 ++++-- test/commands/extended/send_transfer_test.py | 24 ++++-- test/commands/extended/send_trytes_test.py | 22 +++++- .../commands/extended/traverse_bundle_test.py | 25 +++++-- .../commands/create_multisig_address_test.py | 22 +++++- test/multisig/commands/get_digests_test.py | 23 ++++-- .../commands/get_private_keys_test.py | 23 ++++-- .../prepare_multisig_transfer_test.py | 24 ++++-- 45 files changed, 733 insertions(+), 350 deletions(-) diff --git a/iota/api.py b/iota/api.py index 7c43c9f5..d0d40b30 100644 --- a/iota/api.py +++ b/iota/api.py @@ -4,13 +4,10 @@ from typing import Dict, Iterable, Optional, Text -from six import add_metaclass - from iota import AdapterSpec, Address, BundleHash, ProposedTransaction, Tag, \ TransactionHash, TransactionTrytes, TryteString, TrytesCompatible from iota.adapter import BaseAdapter, resolve_adapter -from iota.commands import BaseCommand, CustomCommand, core, \ - discover_commands, extended +from iota.commands import BaseCommand, CustomCommand, core, extended from iota.commands.extended.helpers import Helpers from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed @@ -28,32 +25,6 @@ class InvalidCommand(ValueError): """ pass - -class ApiMeta(type): - """ - Manages command registries for IOTA API base classes. - """ - - def __init__(cls, name, bases=None, attrs=None): - super(ApiMeta, cls).__init__(name, bases, attrs) - - if not hasattr(cls, 'commands'): - cls.commands = {} - - # Copy command registry from base class to derived class, but - # in the event of a conflict, preserve the derived class' - # commands. - commands = {} - for base in bases: - if isinstance(base, ApiMeta): - commands.update(base.commands) - - if commands: - commands.update(cls.commands) - cls.commands = commands - - -@add_metaclass(ApiMeta) class StrictIota(object): """ API to send HTTP requests for communicating with an IOTA node. @@ -82,7 +53,6 @@ class StrictIota(object): :ref:`find out` how to use it. """ - commands = discover_commands('iota.commands.core') def __init__(self, adapter, testnet=False, local_pow=False): # type: (AdapterSpec, bool, bool) -> None @@ -123,48 +93,6 @@ def __init__(self, adapter, testnet=False, local_pow=False): self.adapter.set_local_pow(local_pow) self.testnet = testnet - def __getattr__(self, command): - # type: (Text) -> BaseCommand - """ - Creates a pre-configured command instance. - - This method will only return commands supported by the API - class. - - If you want to execute an arbitrary API command, use - :py:meth:`create_command`. - - :param Text command: - The name of the command to create. - - References: - - - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference - """ - # Fix an error when invoking :py:func:`help`. - # https://github.com/iotaledger/iota.py/issues/41 - if command == '__name__': - # noinspection PyTypeChecker - return None - - # Fix an error when invoking dunder methods. - # https://github.com/iotaledger/iota.py/issues/206 - if command.startswith("__"): - # noinspection PyUnresolvedReferences - return super(StrictIota, self).__getattr__(command) - - try: - command_class = self.commands[command] - except KeyError: - raise InvalidCommand( - '{cls} does not support {command!r} command.'.format( - cls=type(self).__name__, - command=command, - ), - ) - - return command_class(self.adapter) - def create_command(self, command): # type: (Text) -> CustomCommand """ @@ -862,7 +790,6 @@ class Iota(StrictIota): - https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference - https://github.com/iotaledger/wiki/blob/master/api-proposal.md """ - commands = discover_commands('iota.commands.extended') def __init__(self, adapter, seed=None, testnet=False, local_pow=False): # type: (AdapterSpec, Optional[TrytesCompatible], bool, bool) -> None diff --git a/iota/commands/__init__.py b/iota/commands/__init__.py index acfefa36..39695bc8 100644 --- a/iota/commands/__init__.py +++ b/iota/commands/__init__.py @@ -18,76 +18,13 @@ __all__ = [ 'BaseCommand', - 'command_registry', - 'discover_commands', 'CustomCommand', 'FilterCommand', 'RequestFilter', 'ResponseFilter', ] -command_registry = {} # type: Dict[Text, CommandMeta] -""" -Registry of commands, indexed by command name. -""" - - -def discover_commands(package, recursively=True): - # type: (Union[ModuleType, Text], bool) -> Dict[Text, 'CommandMeta'] - """ - Automatically discover commands in the specified package. - - :param package: - Package path or reference. - - :param recursively: - If True, will descend recursively into sub-packages. - - :return: - All commands discovered in the specified package, indexed by - command name (note: not class name). - """ - # http://stackoverflow.com/a/25562415/ - if isinstance(package, six.string_types): - package = import_module(package) # type: ModuleType - - commands = {} - - for _, name, is_package in walk_packages(package.__path__, package.__name__ + '.'): - # Loading the module is good enough; the CommandMeta metaclass will - # ensure that any commands in the module get registered. - - # Prefix in name module move to function "walk_packages" for fix - # conflict with names importing packages - # Bug https://github.com/iotaledger/iota.py/issues/63 - sub_package = import_module(name) - - # Index any command classes that we find. - for (_, obj) in get_members(sub_package): - if is_class(obj) and isinstance(obj, CommandMeta): - command_name = getattr(obj, 'command') - if command_name: - commands[command_name] = obj - - if recursively and is_package: - commands.update(discover_commands(sub_package)) - - return commands - -class CommandMeta(ABCMeta): - """ - Automatically register new commands. - """ - # noinspection PyShadowingBuiltins - def __init__(cls, what, bases=None, dict=None): - super(CommandMeta, cls).__init__(what, bases, dict) - - if not is_abstract(cls): - command = getattr(cls, 'command') - if command: - command_registry[command] = cls - -@six.add_metaclass(CommandMeta) +@six.add_metaclass(ABCMeta) class BaseCommand(object): """ An API command ready to send to the node. @@ -265,6 +202,7 @@ class FilterCommand(BaseCommand): """ Uses filters to manipulate request/response values. """ + @abstract_method def get_request_filter(self): # type: () -> Optional[RequestFilter] @@ -336,8 +274,4 @@ def _apply_filter(value, filter_, failure_message): }, ) - return value - - -# Autodiscover commands in this package. -discover_commands(__name__) + return value \ No newline at end of file diff --git a/iota/multisig/api.py b/iota/multisig/api.py index 563f7ca1..0ad14b8d 100644 --- a/iota/multisig/api.py +++ b/iota/multisig/api.py @@ -5,7 +5,6 @@ from typing import Iterable, Optional from iota import Address, Iota, ProposedTransaction -from iota.commands import discover_commands from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Digest from iota.multisig import commands @@ -31,7 +30,6 @@ class MultisigIota(Iota): - https://github.com/iotaledger/wiki/blob/master/multisigs.md """ - commands = discover_commands('iota.multisig.commands') def create_multisig_address(self, digests): # type: (Iterable[Digest]) -> dict diff --git a/test/__init__.py b/test/__init__.py index 83fdaa2f..0b9065db 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -8,7 +8,9 @@ # In Python 3 the ``mock`` library was moved into the stdlib. # noinspection PyUnresolvedReferences from unittest import mock + from unittest.mock import MagicMock, patch else: # In Python 2, the ``mock`` library is included as a dependency. # noinspection PyUnresolvedReferences import mock + from mock import MagicMock, patch diff --git a/test/api_test.py b/test/api_test.py index 7a60e550..95084f4b 100644 --- a/test/api_test.py +++ b/test/api_test.py @@ -107,27 +107,6 @@ def test_init_with_uri(self): api = StrictIota('mock://') self.assertIsInstance(api.adapter, MockAdapter) - def test_registered_command(self): - """ - Preparing a documented command. - """ - api = StrictIota(MockAdapter()) - - # We just need to make sure the correct command type is - # instantiated; individual commands have their own unit tests. - command = api.getNodeInfo - self.assertIsInstance(command, GetNodeInfoCommand) - - def test_unregistered_command(self): - """ - Attempting to create an unsupported command. - """ - api = StrictIota(MockAdapter()) - - with self.assertRaises(InvalidCommand): - # noinspection PyStatementEffect - api.helloWorld - def test_create_command(self): """ Preparing an experimental/undocumented command. diff --git a/test/commands/core/add_neighbors_test.py b/test/commands/core/add_neighbors_test.py index 367f8780..985c3acb 100644 --- a/test/commands/core/add_neighbors_test.py +++ b/test/commands/core/add_neighbors_test.py @@ -6,10 +6,11 @@ import filters as f from filters.test import BaseFilterTestCase -from iota import Iota +from iota import StrictIota from iota.adapter import MockAdapter from iota.commands.core.add_neighbors import AddNeighborsCommand from iota.filters import NodeUri +from test import patch, MagicMock class AddNeighborsRequestFilterTestCase(BaseFilterTestCase): @@ -151,8 +152,20 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).addNeighbors, - AddNeighborsCommand, - ) + with patch('iota.commands.core.add_neighbors.AddNeighborsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = StrictIota(self.adapter) + + response = api.add_neighbors('test_uri') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) \ No newline at end of file diff --git a/test/commands/core/attach_to_tangle_test.py b/test/commands/core/attach_to_tangle_test.py index 787a0ed0..604057e9 100644 --- a/test/commands/core/attach_to_tangle_test.py +++ b/test/commands/core/attach_to_tangle_test.py @@ -11,6 +11,7 @@ from iota.commands.core.attach_to_tangle import AttachToTangleCommand from iota.filters import Trytes from six import binary_type, text_type +from test import patch, MagicMock class AttachToTangleRequestFilterTestCase(BaseFilterTestCase): @@ -423,7 +424,6 @@ def test_pass_happy_path(self): }, ) - class AttachToTangleCommandTestCase(TestCase): def setUp(self): super(AttachToTangleCommandTestCase, self).setUp() @@ -433,8 +433,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).attachToTangle, - AttachToTangleCommand, - ) + with patch('iota.commands.core.attach_to_tangle.AttachToTangleCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.attach_to_tangle('trunk', 'branch', 'trytes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/broadcast_transactions_test.py b/test/commands/core/broadcast_transactions_test.py index 17c104d9..e5c3b73e 100644 --- a/test/commands/core/broadcast_transactions_test.py +++ b/test/commands/core/broadcast_transactions_test.py @@ -13,7 +13,7 @@ from iota.commands.core.broadcast_transactions import \ BroadcastTransactionsCommand from iota.filters import Trytes - +from test import patch, MagicMock class BroadcastTransactionsRequestFilterTestCase(BaseFilterTestCase): filter_type = BroadcastTransactionsCommand(MockAdapter()).get_request_filter @@ -187,8 +187,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).broadcastTransactions, - BroadcastTransactionsCommand, - ) + with patch('iota.commands.core.broadcast_transactions.BroadcastTransactionsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.broadcast_transactions('trytes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/check_consistency_test.py b/test/commands/core/check_consistency_test.py index 028f39cd..57517df0 100644 --- a/test/commands/core/check_consistency_test.py +++ b/test/commands/core/check_consistency_test.py @@ -11,6 +11,7 @@ from iota.adapter import MockAdapter from iota.commands.core.check_consistency import CheckConsistencyCommand from iota.filters import Trytes +from test import patch, MagicMock class CheckConsistencyRequestFilterTestCase(BaseFilterTestCase): @@ -200,11 +201,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).checkConsistency, - CheckConsistencyCommand, - ) + with patch('iota.commands.core.check_consistency.CheckConsistencyCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.check_consistency('tails') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/core/find_transactions_test.py b/test/commands/core/find_transactions_test.py index cad20fff..3d5a72cb 100644 --- a/test/commands/core/find_transactions_test.py +++ b/test/commands/core/find_transactions_test.py @@ -13,6 +13,7 @@ from iota.commands.core.find_transactions import FindTransactionsCommand, \ FindTransactionsRequestFilter from iota.filters import Trytes +from test import patch, MagicMock class FindTransactionsRequestFilterTestCase(BaseFilterTestCase): @@ -561,8 +562,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).findTransactions, - FindTransactionsCommand, - ) + with patch('iota.commands.core.check_consistency.CheckConsistencyCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.check_consistency('tails') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) \ No newline at end of file diff --git a/test/commands/core/get_balances_test.py b/test/commands/core/get_balances_test.py index 4f9ecc8b..bfd1450d 100644 --- a/test/commands/core/get_balances_test.py +++ b/test/commands/core/get_balances_test.py @@ -11,6 +11,7 @@ from iota.adapter import MockAdapter from iota.commands.core.get_balances import GetBalancesCommand from iota.filters import Trytes +from test import patch, MagicMock class GetBalancesRequestFilterTestCase(BaseFilterTestCase): @@ -351,8 +352,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getBalances, - GetBalancesCommand, - ) + with patch('iota.commands.core.get_balances.GetBalancesCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_balances('addresses') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/get_inclusion_states_test.py b/test/commands/core/get_inclusion_states_test.py index 3a63c754..873a49a1 100644 --- a/test/commands/core/get_inclusion_states_test.py +++ b/test/commands/core/get_inclusion_states_test.py @@ -11,6 +11,7 @@ from iota.commands.core.get_inclusion_states import GetInclusionStatesCommand from iota.filters import Trytes from six import binary_type, text_type +from test import patch, MagicMock class GetInclusionStatesRequestFilterTestCase(BaseFilterTestCase): @@ -259,8 +260,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getInclusionStates, - GetInclusionStatesCommand, - ) + with patch('iota.commands.core.get_inclusion_states.GetInclusionStatesCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_inclusion_states('transactions', 'tips') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/get_missing_transactions_test.py b/test/commands/core/get_missing_transactions_test.py index cb6de746..0c5adb00 100644 --- a/test/commands/core/get_missing_transactions_test.py +++ b/test/commands/core/get_missing_transactions_test.py @@ -10,6 +10,7 @@ from iota import Iota, TransactionHash from iota.adapter import MockAdapter from iota.commands.core import GetMissingTransactionsCommand +from test import patch, MagicMock class GetMissingTransactionsRequestFilterTestCase(BaseFilterTestCase): @@ -109,8 +110,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getMissingTransactions, - GetMissingTransactionsCommand, - ) + with patch('iota.commands.core.get_missing_transactions.GetMissingTransactionsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_missing_transactions() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/get_neighbors_test.py b/test/commands/core/get_neighbors_test.py index be7888f5..83e71172 100644 --- a/test/commands/core/get_neighbors_test.py +++ b/test/commands/core/get_neighbors_test.py @@ -9,6 +9,7 @@ from iota import Iota from iota.adapter import MockAdapter from iota.commands.core.get_neighbors import GetNeighborsCommand +from test import patch, MagicMock class GetNeighborsRequestFilterTestCase(BaseFilterTestCase): @@ -49,8 +50,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getNeighbors, - GetNeighborsCommand, - ) + with patch('iota.commands.core.get_neighbors.GetNeighborsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_neighbors() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/get_node_api_configuration_test.py b/test/commands/core/get_node_api_configuration_test.py index bee9e7a7..9f154873 100644 --- a/test/commands/core/get_node_api_configuration_test.py +++ b/test/commands/core/get_node_api_configuration_test.py @@ -10,6 +10,7 @@ from iota import Iota from iota.adapter import MockAdapter from iota.commands.core import GetNodeAPIConfigurationCommand +from test import patch, MagicMock class GetNodeAPIConfigurationRequestFilterTestCase(BaseFilterTestCase): @@ -53,8 +54,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getNodeAPIConfiguration, - GetNodeAPIConfigurationCommand, - ) + with patch('iota.commands.core.get_node_api_configuration.GetNodeAPIConfigurationCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_node_api_configuration() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/get_node_info_test.py b/test/commands/core/get_node_info_test.py index 00a34021..9927ed74 100644 --- a/test/commands/core/get_node_info_test.py +++ b/test/commands/core/get_node_info_test.py @@ -9,6 +9,7 @@ from iota import Iota, TransactionHash from iota.adapter import MockAdapter from iota.commands.core.get_node_info import GetNodeInfoCommand +from test import patch, MagicMock class GetNodeInfoRequestFilterTestCase(BaseFilterTestCase): @@ -122,8 +123,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getNodeInfo, - GetNodeInfoCommand, - ) + with patch('iota.commands.core.get_node_info.GetNodeInfoCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_node_info() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/get_tips_test.py b/test/commands/core/get_tips_test.py index 03cc9a89..5dfb191d 100644 --- a/test/commands/core/get_tips_test.py +++ b/test/commands/core/get_tips_test.py @@ -11,6 +11,7 @@ from iota.adapter import MockAdapter from iota.commands.core.get_tips import GetTipsCommand from iota.transaction.types import TransactionHash +from test import patch, MagicMock class GetTipsRequestFilterTestCase(BaseFilterTestCase): @@ -120,11 +121,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getTips, - GetTipsCommand, - ) + with patch('iota.commands.core.get_tips.GetTipsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_tips() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_type_coercion(self): """ diff --git a/test/commands/core/get_transactions_to_approve_test.py b/test/commands/core/get_transactions_to_approve_test.py index 89559bf0..b5158000 100644 --- a/test/commands/core/get_transactions_to_approve_test.py +++ b/test/commands/core/get_transactions_to_approve_test.py @@ -11,6 +11,7 @@ from iota.commands.core.get_transactions_to_approve import \ GetTransactionsToApproveCommand from iota.filters import Trytes +from test import patch, MagicMock class GetTransactionsToApproveRequestFilterTestCase(BaseFilterTestCase): @@ -218,17 +219,30 @@ def test_pass_happy_path(self): ) -class GetTransactionsToApproveTestCase(TestCase): +class GetTransactionsToApproveCommandTestCase(TestCase): def setUp(self): - super(GetTransactionsToApproveTestCase, self).setUp() + super(GetTransactionsToApproveCommandTestCase, self).setUp() self.adapter = MockAdapter() def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getTransactionsToApprove, - GetTransactionsToApproveCommand, - ) + with patch('iota.commands.core.get_transactions_to_approve.GetTransactionsToApproveCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_transactions_to_approve('depth') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/get_trytes_test.py b/test/commands/core/get_trytes_test.py index 7d6c2844..2cf36bb0 100644 --- a/test/commands/core/get_trytes_test.py +++ b/test/commands/core/get_trytes_test.py @@ -11,6 +11,7 @@ from iota.adapter import MockAdapter from iota.commands.core.get_trytes import GetTrytesCommand from iota.filters import Trytes +from test import patch, MagicMock class GetTrytesRequestFilterTestCase(BaseFilterTestCase): @@ -242,8 +243,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getTrytes, - GetTrytesCommand, - ) + with patch('iota.commands.core.get_trytes.GetTrytesCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_trytes('hashes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/interrupt_attaching_to_tangle_test.py b/test/commands/core/interrupt_attaching_to_tangle_test.py index 4580b867..3bcb8c76 100644 --- a/test/commands/core/interrupt_attaching_to_tangle_test.py +++ b/test/commands/core/interrupt_attaching_to_tangle_test.py @@ -10,6 +10,7 @@ from iota.adapter import MockAdapter from iota.commands.core.interrupt_attaching_to_tangle import \ InterruptAttachingToTangleCommand +from test import patch, MagicMock class InterruptAttachingToTangleRequestFilterTestCase(BaseFilterTestCase): @@ -48,8 +49,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).interruptAttachingToTangle, - InterruptAttachingToTangleCommand, - ) + with patch('iota.commands.core.interrupt_attaching_to_tangle.InterruptAttachingToTangleCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.interrupt_attaching_to_tangle() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/remove_neighbors_test.py b/test/commands/core/remove_neighbors_test.py index b05c532f..32ca749e 100644 --- a/test/commands/core/remove_neighbors_test.py +++ b/test/commands/core/remove_neighbors_test.py @@ -10,6 +10,7 @@ from iota.adapter import MockAdapter from iota.commands.core.remove_neighbors import RemoveNeighborsCommand from iota.filters import NodeUri +from test import patch, MagicMock class RemoveNeighborsRequestFilterTestCase(BaseFilterTestCase): @@ -153,8 +154,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).removeNeighbors, - RemoveNeighborsCommand, - ) + with patch('iota.commands.core.remove_neighbors.RemoveNeighborsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.remove_neighbors('uris') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/store_transactions_test.py b/test/commands/core/store_transactions_test.py index 57e20457..b1a87d1f 100644 --- a/test/commands/core/store_transactions_test.py +++ b/test/commands/core/store_transactions_test.py @@ -12,6 +12,7 @@ from iota.adapter import MockAdapter from iota.commands.core.store_transactions import StoreTransactionsCommand from iota.filters import Trytes +from test import patch, MagicMock class StoreTransactionsRequestFilterTestCase(BaseFilterTestCase): @@ -187,8 +188,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).storeTransactions, - StoreTransactionsCommand, - ) + with patch('iota.commands.core.store_transactions.StoreTransactionsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.store_transactions('trytes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/core/were_addresses_spent_from_test.py b/test/commands/core/were_addresses_spent_from_test.py index 4fd482e1..399a197b 100644 --- a/test/commands/core/were_addresses_spent_from_test.py +++ b/test/commands/core/were_addresses_spent_from_test.py @@ -11,6 +11,7 @@ from iota.adapter import MockAdapter from iota.commands.core import WereAddressesSpentFromCommand from iota.filters import Trytes +from test import patch, MagicMock class WereAddressesSpentFromRequestFilterTestCase(BaseFilterTestCase): @@ -169,8 +170,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).wereAddressesSpentFrom, - WereAddressesSpentFromCommand, - ) + with patch('iota.commands.core.were_addresses_spent_from.WereAddressesSpentFromCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.were_addresses_spent_from('addresses') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/extended/broadcast_and_store_test.py b/test/commands/extended/broadcast_and_store_test.py index 6e02dd27..9354784e 100644 --- a/test/commands/extended/broadcast_and_store_test.py +++ b/test/commands/extended/broadcast_and_store_test.py @@ -9,6 +9,7 @@ from iota import Iota, TransactionTrytes from iota.adapter import MockAdapter from iota.commands.extended.broadcast_and_store import BroadcastAndStoreCommand +from test import patch, MagicMock class BroadcastAndStoreCommandTestCase(TestCase): @@ -27,11 +28,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).broadcastAndStore, - BroadcastAndStoreCommand, - ) + with patch('iota.commands.extended.broadcast_and_store.BroadcastAndStoreCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.broadcast_and_store('trytes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/broadcast_bundle_test.py b/test/commands/extended/broadcast_bundle_test.py index b6014ac7..3d047801 100644 --- a/test/commands/extended/broadcast_bundle_test.py +++ b/test/commands/extended/broadcast_bundle_test.py @@ -12,13 +12,8 @@ from iota.adapter import MockAdapter from iota.commands.extended.broadcast_bundle import BroadcastBundleCommand from iota.filters import Trytes +from test import patch, MagicMock -from six import PY2 - -if PY2: - from mock import MagicMock, patch -else: - from unittest.mock import MagicMock, patch # RequestFilterTestCase code reused from get_bundles_test.py class BroadcastBundleRequestFilterTestCase(BaseFilterTestCase): @@ -148,11 +143,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).broadcastBundle, - BroadcastBundleCommand, - ) + with patch('iota.commands.extended.broadcast_bundle.BroadcastBundleCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.broadcast_bundle('trytes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/find_transaction_objects.py b/test/commands/extended/find_transaction_objects.py index a98b97c7..e87b3805 100644 --- a/test/commands/extended/find_transaction_objects.py +++ b/test/commands/extended/find_transaction_objects.py @@ -4,10 +4,9 @@ from unittest import TestCase -import mock - from iota import Iota, MockAdapter, Transaction from iota.commands.extended import FindTransactionObjectsCommand +from test import patch, MagicMock, mock class FindTransactionObjectsCommandTestCase(TestCase): @@ -72,11 +71,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).findTransactionObjects, - FindTransactionObjectsCommand, - ) + with patch('iota.commands.extended.find_transaction_objects.FindTransactionObjectsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.find_transaction_objects('bundle') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_transaction_found(self): """ diff --git a/test/commands/extended/get_account_data_test.py b/test/commands/extended/get_account_data_test.py index 649ac39f..1a0e524a 100644 --- a/test/commands/extended/get_account_data_test.py +++ b/test/commands/extended/get_account_data_test.py @@ -15,6 +15,7 @@ from iota.crypto.types import Seed from iota.filters import Trytes from test import mock +from test import patch, MagicMock class GetAccountDataRequestFilterTestCase(BaseFilterTestCase): @@ -362,11 +363,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getAccountData, - GetAccountDataCommand, - ) + with patch('iota.commands.extended.get_account_data.GetAccountDataCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_account_data() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/get_bundles_test.py b/test/commands/extended/get_bundles_test.py index 2a1de500..4d596fb4 100644 --- a/test/commands/extended/get_bundles_test.py +++ b/test/commands/extended/get_bundles_test.py @@ -12,6 +12,7 @@ from iota.adapter import MockAdapter from iota.commands.extended.get_bundles import GetBundlesCommand from iota.filters import Trytes +from test import patch, MagicMock class GetBundlesRequestFilterTestCase(BaseFilterTestCase): @@ -274,12 +275,25 @@ def setUp(self): def test_wireup(self): """ - Verifies that the command is wired up correctly. + Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getBundles, - GetBundlesCommand, - ) + with patch('iota.commands.extended.get_bundles.GetBundlesCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_bundles('transaction') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/get_inputs_test.py b/test/commands/extended/get_inputs_test.py index ee3594ab..439a687f 100644 --- a/test/commands/extended/get_inputs_test.py +++ b/test/commands/extended/get_inputs_test.py @@ -14,6 +14,7 @@ from iota.crypto.types import Seed from iota.filters import Trytes from test import mock +from test import patch, MagicMock class GetInputsRequestFilterTestCase(BaseFilterTestCase): @@ -441,11 +442,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getInputs, - GetInputsCommand, - ) + with patch('iota.commands.extended.get_inputs.GetInputsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_inputs() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_stop_threshold_met(self): """ diff --git a/test/commands/extended/get_latest_inclusion_test.py b/test/commands/extended/get_latest_inclusion_test.py index 375f631e..4680693d 100644 --- a/test/commands/extended/get_latest_inclusion_test.py +++ b/test/commands/extended/get_latest_inclusion_test.py @@ -12,6 +12,7 @@ from iota.commands.extended.get_latest_inclusion import \ GetLatestInclusionCommand from iota.filters import Trytes +from test import patch, MagicMock class GetLatestInclusionRequestFilterTestCase(BaseFilterTestCase): @@ -204,11 +205,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getLatestInclusion, - GetLatestInclusionCommand, - ) + with patch('iota.commands.extended.get_latest_inclusion.GetLatestInclusionCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_latest_inclusion('hashes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/get_new_addresses_test.py b/test/commands/extended/get_new_addresses_test.py index 53730e78..bf14b0a1 100644 --- a/test/commands/extended/get_new_addresses_test.py +++ b/test/commands/extended/get_new_addresses_test.py @@ -13,6 +13,7 @@ from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed from iota.filters import Trytes +from test import patch, MagicMock class GetNewAddressesRequestFilterTestCase(BaseFilterTestCase): @@ -365,11 +366,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getNewAddresses, - GetNewAddressesCommand, - ) + with patch('iota.commands.extended.get_new_addresses.GetNewAddressesCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_new_addresses('hashes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_get_addresses_offline(self): """ diff --git a/test/commands/extended/get_transaction_objects_test.py b/test/commands/extended/get_transaction_objects_test.py index 16fddbce..552889a4 100644 --- a/test/commands/extended/get_transaction_objects_test.py +++ b/test/commands/extended/get_transaction_objects_test.py @@ -4,10 +4,9 @@ from unittest import TestCase -import mock - from iota import Iota, MockAdapter, Transaction from iota.commands.extended import GetTransactionObjectsCommand +from test import patch, MagicMock, mock class GetTransactionObjectsCommandTestCase(TestCase): @@ -71,11 +70,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getTransactionObjects, - GetTransactionObjectsCommand, - ) + with patch('iota.commands.extended.get_transaction_objects.GetTransactionObjectsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_transaction_objects('hashes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_transaction_found(self): """ diff --git a/test/commands/extended/get_transfers_test.py b/test/commands/extended/get_transfers_test.py index 4b64d707..7373daf4 100644 --- a/test/commands/extended/get_transfers_test.py +++ b/test/commands/extended/get_transfers_test.py @@ -15,6 +15,7 @@ from iota.crypto.types import Seed from iota.filters import Trytes from test import mock +from test import patch, MagicMock class GetTransfersRequestFilterTestCase(BaseFilterTestCase): @@ -339,12 +340,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).getTransfers, - GetTransfersCommand, - ) + with patch('iota.commands.extended.get_transfers.GetTransfersCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + # Don't need to call with proper args here. + response = api.get_transfers() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_full_scan(self): """ Scanning the Tangle for all transfers. diff --git a/test/commands/extended/is_promotable_test.py b/test/commands/extended/is_promotable_test.py index ad991789..cc1506a3 100644 --- a/test/commands/extended/is_promotable_test.py +++ b/test/commands/extended/is_promotable_test.py @@ -14,6 +14,7 @@ get_current_ms, is_within_depth, MILESTONE_INTERVAL, ONE_WAY_DELAY from iota.filters import Trytes from test import mock +from test import patch, MagicMock class IsPromotableRequestFilterTestCase(BaseFilterTestCase): filter_type = IsPromotableCommand(MockAdapter()).get_request_filter @@ -290,11 +291,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).isPromotable, - IsPromotableCommand, - ) + with patch('iota.commands.extended.is_promotable.IsPromotableCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.is_promotable('tails') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/is_reattachable_test.py b/test/commands/extended/is_reattachable_test.py index edef5fd5..2800b069 100644 --- a/test/commands/extended/is_reattachable_test.py +++ b/test/commands/extended/is_reattachable_test.py @@ -11,6 +11,7 @@ from iota import Address, Iota from iota.adapter import MockAdapter from iota.commands.extended.is_reattachable import IsReattachableCommand +from test import patch, MagicMock class IsReattachableRequestFilterTestCase(BaseFilterTestCase): @@ -199,8 +200,21 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).isReattachable, - IsReattachableCommand, - ) + with patch('iota.commands.extended.is_reattachable.IsReattachableCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.is_reattachable('addresses') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) diff --git a/test/commands/extended/prepare_transfer_test.py b/test/commands/extended/prepare_transfer_test.py index 35e0f9df..0a6c46b6 100644 --- a/test/commands/extended/prepare_transfer_test.py +++ b/test/commands/extended/prepare_transfer_test.py @@ -16,6 +16,7 @@ from iota.crypto.types import Seed from iota.filters import GeneratedAddress, Trytes from test import mock +from test import patch, MagicMock class PrepareTransferRequestFilterTestCase(BaseFilterTestCase): @@ -576,11 +577,24 @@ def get_current_timestamp(): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).prepareTransfer, - PrepareTransferCommand, - ) + with patch('iota.commands.extended.prepare_transfer.PrepareTransferCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.prepare_transfer('transfers') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_pass_inputs_not_needed(self): """ diff --git a/test/commands/extended/promote_transaction_test.py b/test/commands/extended/promote_transaction_test.py index 21f5b815..e732ae4f 100644 --- a/test/commands/extended/promote_transaction_test.py +++ b/test/commands/extended/promote_transaction_test.py @@ -13,6 +13,7 @@ from iota.commands.extended.promote_transaction import PromoteTransactionCommand from iota.filters import Trytes from test import mock +from test import patch, MagicMock class PromoteTransactionRequestFilterTestCase(BaseFilterTestCase): @@ -311,12 +312,25 @@ def setUp(self): def test_wireup(self): """ - Verifies that the command is wired-up correctly. + Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).promoteTransaction, - PromoteTransactionCommand, - ) + with patch('iota.commands.extended.promote_transaction.PromoteTransactionCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.promote_transaction('transaction') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/replay_bundle_test.py b/test/commands/extended/replay_bundle_test.py index b6079bfd..858e038b 100644 --- a/test/commands/extended/replay_bundle_test.py +++ b/test/commands/extended/replay_bundle_test.py @@ -14,6 +14,7 @@ from iota.commands.extended.replay_bundle import ReplayBundleCommand from iota.filters import Trytes from test import mock +from test import patch, MagicMock class ReplayBundleRequestFilterTestCase(BaseFilterTestCase): @@ -303,12 +304,25 @@ def setUp(self): def test_wireup(self): """ - Verifies that the command is wired-up correctly. + Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).replayBundle, - ReplayBundleCommand, - ) + with patch('iota.commands.extended.replay_bundle.ReplayBundleCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.replay_bundle('transaction') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/send_transfer_test.py b/test/commands/extended/send_transfer_test.py index aaedac8d..8a277c0e 100644 --- a/test/commands/extended/send_transfer_test.py +++ b/test/commands/extended/send_transfer_test.py @@ -16,6 +16,7 @@ from iota.crypto.types import Seed from iota.filters import Trytes from test import mock +from test import patch, MagicMock class SendTransferRequestFilterTestCase(BaseFilterTestCase): @@ -671,12 +672,25 @@ def setUp(self): def test_wireup(self): """ - Verifies that the command is wired up correctly. + Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).sendTransfer, - SendTransferCommand, - ) + with patch('iota.commands.extended.send_transfer.SendTransferCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.send_transfer('transfers') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/send_trytes_test.py b/test/commands/extended/send_trytes_test.py index b2d4d02e..70301806 100644 --- a/test/commands/extended/send_trytes_test.py +++ b/test/commands/extended/send_trytes_test.py @@ -12,6 +12,7 @@ from iota.adapter import MockAdapter from iota.commands.extended.send_trytes import SendTrytesCommand from iota.filters import Trytes +from test import patch, MagicMock class SendTrytesRequestFilterTestCase(BaseFilterTestCase): @@ -371,11 +372,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).sendTrytes, - SendTrytesCommand, - ) + with patch('iota.commands.extended.send_trytes.SendTrytesCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.send_trytes('trytes') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/commands/extended/traverse_bundle_test.py b/test/commands/extended/traverse_bundle_test.py index e700c776..f28066fd 100644 --- a/test/commands/extended/traverse_bundle_test.py +++ b/test/commands/extended/traverse_bundle_test.py @@ -12,7 +12,7 @@ from iota.adapter import MockAdapter from iota.commands.extended.traverse_bundle import TraverseBundleCommand from iota.filters import Trytes - +from test import patch, MagicMock # Same tests as for GetBundlesRequestFilter (it is the same filter) class TraverseBundleRequestFilterTestCase(BaseFilterTestCase): @@ -129,12 +129,25 @@ def setUp(self): def test_wireup(self): """ - Verifies that the command is wired up correctly. + Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - Iota(self.adapter).traverseBundle, - TraverseBundleCommand, - ) + with patch('iota.commands.extended.traverse_bundle.TraverseBundleCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = Iota(self.adapter) + + # Don't need to call with proper args here. + response = api.traverse_bundle('tail') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_single_transaction(self): """ diff --git a/test/multisig/commands/create_multisig_address_test.py b/test/multisig/commands/create_multisig_address_test.py index 301fd9eb..be7f3adc 100644 --- a/test/multisig/commands/create_multisig_address_test.py +++ b/test/multisig/commands/create_multisig_address_test.py @@ -15,6 +15,7 @@ from iota.multisig import MultisigIota from iota.multisig.commands import CreateMultisigAddressCommand from iota.multisig.types import MultisigAddress +from test import patch, MagicMock class CreateMultisigAddressCommandTestCase(TestCase): @@ -49,11 +50,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - MultisigIota(self.adapter).createMultisigAddress, - CreateMultisigAddressCommand, - ) + with patch('iota.multisig.commands.create_multisig_address.CreateMultisigAddressCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = MultisigIota(self.adapter) + + # Don't need to call with proper args here. + response = api.create_multisig_address('digests') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ diff --git a/test/multisig/commands/get_digests_test.py b/test/multisig/commands/get_digests_test.py index fa96c477..daa88c20 100644 --- a/test/multisig/commands/get_digests_test.py +++ b/test/multisig/commands/get_digests_test.py @@ -16,7 +16,7 @@ from iota.filters import Trytes from iota.multisig import MultisigIota from iota.multisig.commands import GetDigestsCommand -from test import mock +from test import mock, patch, MagicMock class GetDigestsCommandTestCase(TestCase): @@ -37,11 +37,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - MultisigIota(self.adapter).getDigests, - GetDigestsCommand, - ) + with patch('iota.multisig.commands.get_digests.GetDigestsCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = MultisigIota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_digests() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_generate_single_digest(self): """ diff --git a/test/multisig/commands/get_private_keys_test.py b/test/multisig/commands/get_private_keys_test.py index c68d5fb4..a6dc7d54 100644 --- a/test/multisig/commands/get_private_keys_test.py +++ b/test/multisig/commands/get_private_keys_test.py @@ -16,7 +16,7 @@ from iota.filters import Trytes from iota.multisig import MultisigIota from iota.multisig.commands import GetPrivateKeysCommand -from test import mock +from test import mock, patch, MagicMock class GetPrivateKeysCommandTestCase(TestCase): @@ -41,11 +41,24 @@ def setUp(self): def test_wireup(self): """ Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - MultisigIota(self.adapter).getPrivateKeys, - GetPrivateKeysCommand, - ) + with patch('iota.multisig.commands.get_private_keys.GetPrivateKeysCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = MultisigIota(self.adapter) + + # Don't need to call with proper args here. + response = api.get_private_keys() + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_generate_single_key(self): """ diff --git a/test/multisig/commands/prepare_multisig_transfer_test.py b/test/multisig/commands/prepare_multisig_transfer_test.py index 9cb01990..bbf4ac6b 100644 --- a/test/multisig/commands/prepare_multisig_transfer_test.py +++ b/test/multisig/commands/prepare_multisig_transfer_test.py @@ -14,6 +14,7 @@ from iota.multisig import MultisigIota from iota.multisig.commands import PrepareMultisigTransferCommand from iota.multisig.types import MultisigAddress +from test import patch, MagicMock class PrepareMultisigTransferRequestFilterTestCase(BaseFilterTestCase): @@ -526,12 +527,25 @@ def setUp(self): def test_wireup(self): """ - Verifies the command is wired up correctly. + Verify that the command is wired up correctly. + + The API method indeed calls the appropiate command. """ - self.assertIsInstance( - MultisigIota(self.adapter).prepareMultisigTransfer, - PrepareMultisigTransferCommand, - ) + with patch('iota.multisig.commands.prepare_multisig_transfer.PrepareMultisigTransferCommand.__call__', + MagicMock(return_value='You found me!') + ) as mocked_command: + + api = MultisigIota(self.adapter) + + # Don't need to call with proper args here. + response = api.prepare_multisig_transfer('transfer', 'multisig_input') + + self.assertTrue(mocked_command.called) + + self.assertEqual( + response, + 'You found me!' + ) def test_happy_path(self): """ From be3987562e84be724dcabee7b03b86ebfb091d22 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Mon, 9 Dec 2019 17:37:36 +0100 Subject: [PATCH 18/20] docs: add `PyOTA Commands` page Explain the inner workings of an API command for advanced users/developers of the the library. --- docs/commands.rst | 217 ++++++++++++++++++++++++++++++ docs/images/command_execution.svg | 3 + docs/index.rst | 1 + iota/filters.py | 52 ++++++- 4 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 docs/commands.rst create mode 100644 docs/images/command_execution.svg diff --git a/docs/commands.rst b/docs/commands.rst new file mode 100644 index 00000000..a860a441 --- /dev/null +++ b/docs/commands.rst @@ -0,0 +1,217 @@ +Advanced: PyOTA Commands +======================== + +.. note:: + **This page contains information about how PyOTA works under the hood.** + + It is absolutely not necessary to be familiar with the content described + below if you just want to use the library. + + However, if you are a curious mind or happen to do development on the + library, the following information might be useful. + +PyOTA provides the API interface (:ref:`Core API Methods` and +:ref:`Extended API Methods`) for users of the library. These handle +constructing and sending HTTP requests to the specified node through adapters, +furthermore creating, transforming and translating between PyOTA-specific types +and (JSON-encoded) raw data. They also filter outgoing requests and incoming +responses to ensure that only appropriate data is communicated with the node. + +PyOTA implements the `Command Design Pattern`_. High level API interface +methods (:ref:`Core API Methods` and :ref:`Extended API Methods`) +internally call PyOTA commands to get the job done. + +Most PyOTA commands are sub-classed from :py:class:`FilterCommand` class, which +is in turn sub-classed from :py:class:`BaseCommand` class. The reason for the +2-level inheritance is simple: separating functionality. As the name implies, +:py:class:`FilterCommand` adds filtering capabilities to +:py:class:`BaseCommand`, that contains the logic of constructing the request +and using its adapter to send it and receive a response. + +Command Flow +------------ +As mentioned earlier, API methods rely on PyOTA commands to carry out +specific operations. It is important to understand what happens during command +execution so you are able to implement new methods that extend the current +capabilities of PyOTA. + +.. py:currentmodule:: iota + +Let's investigate the process through an example of a core API method, for +instance :py:meth:`~Iota.find_transactions`, that calls +:py:class:`FindTransactionCommand` PyOTA command internally. + +.. note:: + :py:class:`FindTransactionCommand` is sub-classed from :py:class:`FilterCommand`. + +To illustrate what the happens inside the API method, take a look at the +following figure + + +.. figure:: images/command_execution.svg + :scale: 100 % + :alt: Inner workings of a PyOTA Command. + + Inner workings of a PyOTA Command. + +- When you call :py:meth:`~Iota.find_transactions` core API method, it + initializes a :py:class:`FindTransactionCommand` object with the adapter of the + API instance it belongs to. + +- Then calls this command with the keyword arguments it was provided with. + +- The command prepares the request by applying a ``RequestFilter`` on the + payload. The command specific ``RequestFilter`` validates that the payload + has correct types, in some cases it is even able to convert the payload to + the required type and format. + +- Command execution injects the name of the API command (see `IRI API Reference`_ + for command names) in the request and sends it to the adapter. + +- The adapter communicates with the node and returns its response. + +- The response is prepared by going through a command-specific + ``ResponseFilter``. + +- The response is returned to the high level API method as a ``dict``, ready + to be returned to the main application. + +.. note:: + A command object can only be called once without resetting it. When you + use the high level API methods, you don't need to worry about resetting + commands as each call to an API method will initialize a new command object. + +Filters +------- + +If you take a look at the actual implementation of +:py:class:`FindTransactionsCommand`, you notice that you have to define your +own request and response filter classes. + +Filters in PyOTA are based on the `Filters library`_. Read more about how +they work at the `filters documentation site`_. + +In short, you can create filter chains through which the filtered value passes, +and generates errors if something failed validation. Filter chains are specified +in the custom filter class's :py:meth:`__init__` function. If you also want to +modify the filtered value before returning it, override the :py:meth:`_apply` +method of its base class. Read more about how to `create custom filters`_. + +PyOTA offers you some custom filters for PyOTA-specific types: + +**Trytes** +~~~~~~~~~~ +.. autoclass:: iota.filters.Trytes + +**StringifiedTrytesArray** +~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. automethod:: iota.filters.StringifiedTrytesArray + +**AddressNoChecksum** +~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: iota.filters.AddressNoChecksum + +**GeneratedAddress** +~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: iota.filters.GeneratedAddress + +**NodeUri** +~~~~~~~~~~~ +.. autoclass:: iota.filters.NodeUri + :members: SCHEMES + +**SecurityLevel** +~~~~~~~~~~~~~~~~~~~~ +.. automethod:: iota.filters.SecurityLevel + +.. important:: + The general rule in PyOTA is that all requests going to a node are + validated, but only responses that contain transaction/bundle trytes or + hashes are checked. + + Also note, that for extended commands, ``ResponseFilter`` is usually + implemented with just a "pass" statement. The reason being that these + commands do not directly receive their result a node, but rather from + core commands that do have their ``ResponseFilter`` implemented. + More about this topic in the next section. + + +Extended Commands +----------------- + +Core commands, like :py:meth:`~Iota.find_transactions` in the example above, +are for direct communication with the node for simple tasks such +as finding a transaction on the Tangle or getting info about the node. +Extended commands (that serve :ref:`Extended API Methods`) on the other hand +carry out more complex operations such as combining core commands, building +objects, etc... + +As a consequence, extended commands override the default execution phase of their +base class. + +Observe for example :py:class:`FindTransactionObjectsCommand` extended command +that is called in :py:meth:`~Iota.find_transaction_objects` extended API +method. It overrides the :py:meth:`_execute` method of its base class. + +Let's take a closer look at the implementation:: + + ... + def _execute(self, request): + bundles = request\ + .get('bundles') # type: Optional[Iterable[BundleHash]] + addresses = request\ + .get('addresses') # type: Optional[Iterable[Address]] + tags = request\ + .get('tags') # type: Optional[Iterable[Tag]] + approvees = request\ + .get('approvees') # type: Optional[Iterable[TransactionHash]] + + ft_response = FindTransactionsCommand(adapter=self.adapter)( + bundles=bundles, + addresses=addresses, + tags=tags, + approvees=approvees, + ) + + hashes = ft_response['hashes'] + transactions = [] + if hashes: + gt_response = GetTrytesCommand(adapter=self.adapter)(hashes=hashes) + + transactions = list(map( + Transaction.from_tryte_string, + gt_response.get('trytes') or [], + )) # type: List[Transaction] + + return { + 'transactions': transactions, + } + ... + +Instead of sending the request to the adapter, +:py:meth:`FindTransactionObjectsCommand._execute` calls +:py:class:`FindTransactionsCommand` core command, gathers the transaction hashes +that it found, and collects the trytes of those transactions by calling +:py:class:`GetTrytesCommand` core command. Finally, using the obtained trytes, +it constructs a list of transaction objects that are returned to +:py:meth:`~Iota.find_transaction_objects`. + +.. important:: + If you come up with a new functionality for the PyOTA API, please raise + an issue in the `PyOTA Bug Tracker`_ to facilitate discussion. + + Once the community agrees on your proposal, you may start implementing + a new extended API method and the corresponding extended PyOTA command. + + Contributions are always welcome! :) + + Visit the `Contributing to PyOTA`_ page to find out how you can make a + difference! + +.. _Command Design Pattern: https://en.wikipedia.org/wiki/Command_pattern +.. _IRI API Reference: https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference +.. _Filters library: https://pypi.org/project/phx-filters/ +.. _filters documentation site: https://filters.readthedocs.io/en/latest/ +.. _create custom filters: https://filters.readthedocs.io/en/latest/writing_filters.html +.. _PyOTA Bug Tracker: https://github.com/iotaledger/iota.py/issues +.. _Contributing to PyOTA: https://github.com/iotaledger/iota.py/blob/master/CONTRIBUTING.rst \ No newline at end of file diff --git a/docs/images/command_execution.svg b/docs/images/command_execution.svg new file mode 100644 index 00000000..5f1b4b8d --- /dev/null +++ b/docs/images/command_execution.svg @@ -0,0 +1,3 @@ + + +
Create Command Object
- specify adapter
[Not supported by viewer]
Call Command()
- pass keyword arguments
[Not supported by viewer]
Prepare Request
[Not supported by viewer]
Prepare Response
[Not supported by viewer]
Execute Command
[Not supported by viewer]
Return Response To Caller
- dictionary type
[Not supported by viewer]
Reset Command
- if you need the same object
[Not supported by viewer]
Request Filter
[Not supported by viewer]
Response Filter
[Not supported by viewer]
Adapter
[Not supported by viewer]
Node
[Not supported by viewer]
network
[Not supported by viewer]
library
[Not supported by viewer]
\ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index a8432a97..5c654c0f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,5 +11,6 @@ extended_api addresses multisig + commands .. include:: ../README.rst diff --git a/iota/filters.py b/iota/filters.py index 88f3cbff..9025a817 100644 --- a/iota/filters.py +++ b/iota/filters.py @@ -24,7 +24,13 @@ class GeneratedAddress(f.BaseFilter): """ Validates an incoming value as a generated :py:class:`Address` (must - have ``key_index`` set). + have ``key_index`` and ``security_level`` set). + + When a value doesn't pass the filter, a ``ValueError`` is raised with lots + of contextual info attached to it. + + :return: + :py:class:`GeneratedAddress` object. """ CODE_NO_KEY_INDEX = 'no_key_index' CODE_NO_SECURITY_LEVEL = 'no_security_level' @@ -55,6 +61,12 @@ def _apply(self, value): class NodeUri(f.BaseFilter): """ Validates a string as a node URI. + + When a value doesn't pass the filter, a ``ValueError`` is raised with lots + of contextual info attached to it. + + :return: + :py:class:`NodeUri` object. """ SCHEMES = {'tcp', 'udp'} """ @@ -87,6 +99,9 @@ def _apply(self, value): def SecurityLevel(): """ Generates a filter chain for validating a security level. + + :return: + :py:class:`filters.FilterChain` object. """ return ( f.Type(int) | @@ -99,6 +114,21 @@ def SecurityLevel(): class Trytes(f.BaseFilter): """ Validates a sequence as a sequence of trytes. + + When a value doesn't pass the filter, a ``ValueError`` is raised with lots + of contextual info attached to it. + + :param TryteString result_type: + Any subclass of :py:class:`~iota.TryteString` that you want the filter + to validate. + + :raises TypeError: if value is not of ``result_type``. + :raises ValueError: + if ``result_type`` is not of :py:class:`~iota.TryteString` type. + + :return: + :py:class:`Trytes` object. + """ CODE_NOT_TRYTES = 'not_trytes' CODE_WRONG_FORMAT = 'wrong_format' @@ -190,6 +220,16 @@ def StringifiedTrytesArray(trytes_type=TryteString): strings corresponding to the specified type (e.g., ``TransactionHash``). + When a value doesn't pass the filter, a ``ValueError`` is raised with lots + of contextual info attached to it. + + :param TryteString result_type: + Any subclass of :py:class:`~iota.TryteString` that you want the filter + to validate. + + :return: + :py:class:`filters.FilterChain` object. + .. important:: This filter will return string values, suitable for inclusion in an API request. If you are expecting objects (e.g., @@ -209,8 +249,14 @@ def StringifiedTrytesArray(trytes_type=TryteString): class AddressNoChecksum(Trytes): """ - Validates a sequence as an Address, then chops off the checksum if - present. + Validates a sequence as an :py:class:`Address`, then chops off the checksum + if present. + + When a value doesn't pass the filter, a ``ValueError`` is raised with lots + of contextual info attached to it. + + :return: + :py:class:`AddressNoChecksum` object. """ ADDRESS_BAD_CHECKSUM = 'address_bad_checksum' From 51170b89e8627aae0a3c8dd323d44768e6779148 Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Tue, 10 Dec 2019 13:47:00 +0100 Subject: [PATCH 19/20] Remove Sandbox Adapter Sandbox environment not in use anymore. --- docs/adapters.rst | 1 - docs/getting_started.rst | 28 --- examples/sandbox.py | 52 ----- iota/adapter/sandbox.py | 289 ------------------------ test/adapter/sandbox_test.py | 414 ----------------------------------- test/local_pow_test.py | 19 -- 6 files changed, 803 deletions(-) delete mode 100644 examples/sandbox.py delete mode 100644 iota/adapter/sandbox.py delete mode 100644 test/adapter/sandbox_test.py diff --git a/docs/adapters.rst b/docs/adapters.rst index 83940d07..79307f30 100644 --- a/docs/adapters.rst +++ b/docs/adapters.rst @@ -13,7 +13,6 @@ the networking, communicating with a node. You can choose and configure the available adapters to be used with the API: - HttpAdapter, - - SandboxAdapter, - MockAdapter. AdapterSpec diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 7eb8d1a5..574ebb55 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -42,7 +42,6 @@ You can: - `Run your own node.`_ - `Use a light wallet node.`_ -- `Use the sandbox node.`_ Note that light wallet nodes often disable certain features like PoW for security reasons. @@ -68,32 +67,6 @@ Test your connection to the server by sending a ``getNodeInfo`` command: You are now ready to send commands to your IOTA node! -Using the Sandbox Node ----------------------- -To connect to the sandbox node, you will need to inject a -:py:class:`SandboxAdapter` into your :py:class:`Iota` object. This will modify -your API requests so that they contain the necessary authentication metadata. - -.. code-block:: python - - from iota.adapter.sandbox import SandboxAdapter - - api = Iota( - # To use sandbox mode, inject a ``SandboxAdapter``. - adapter = SandboxAdapter( - # URI of the sandbox node. - uri = 'https://sandbox.iotatoken.com/api/v1/', - - # Access token used to authenticate requests. - # Contact the node maintainer to get an access token. - auth_token = 'auth token goes here', - ), - - # Seed used for cryptographic functions. - # If null, a random seed will be generated. - seed = b'SEED9GOES9HERE', - ) - .. _forum: https://forum.iota.org/ .. _official api: https://docs.iota.org/docs/node-software/0.1/iri/references/api-reference .. _pyota-ccurl extension: https://pypi.python.org/pypi/PyOTA-CCurl @@ -101,4 +74,3 @@ your API requests so that they contain the necessary authentication metadata. .. _run your own node.: http://iotasupport.com/headlessnode.shtml .. _slack: http://slack.iota.org/ .. _use a light wallet node.: http://iotasupport.com/lightwallet.shtml -.. _use the sandbox node.: http://dev.iota.org/sandbox diff --git a/examples/sandbox.py b/examples/sandbox.py deleted file mode 100644 index bbaac855..00000000 --- a/examples/sandbox.py +++ /dev/null @@ -1,52 +0,0 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function, \ - unicode_literals - -from iota import Address, Iota, ProposedTransaction, Tag, TryteString -from iota.adapter.sandbox import SandboxAdapter - -# Create the API client. -api = Iota( - # To use sandbox mode, inject a ``SandboxAdapter``. - adapter=SandboxAdapter( - # URI of the sandbox node. - uri='https://sandbox.iota.org/api/v1/', - - # Access token used to authenticate requests. - # Contact the node maintainer to get an access token. - auth_token='auth token goes here', - ), - - # Seed used for cryptographic functions. - # If null, a random seed will be generated. - seed=b'SEED9GOES9HERE', -) - -# Example of sending a transfer using the sandbox. -# For more information, see :py:meth:`Iota.send_transfer`. -# noinspection SpellCheckingInspection -api.send_transfer( - depth=3, - - # One or more :py:class:`ProposedTransaction` objects to add to the - # bundle. - transfers=[ - ProposedTransaction( - # Recipient of the transfer. - address=Address( - b'TESTVALUE9DONTUSEINPRODUCTION99999FBFFTG' - b'QFWEHEL9KCAFXBJBXGE9HID9XCOHFIDABHDG9AHDR' - ), - - # Amount of IOTA to transfer. - # This value may be zero. - value=42, - - # Optional tag to attach to the transfer. - tag=Tag(b'EXAMPLE'), - - # Optional message to include with the transfer. - message=TryteString.from_string('Hello, Tangle!'), - ), - ], -) diff --git a/iota/adapter/sandbox.py b/iota/adapter/sandbox.py deleted file mode 100644 index 1df9a425..00000000 --- a/iota/adapter/sandbox.py +++ /dev/null @@ -1,289 +0,0 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function, \ - unicode_literals - -from time import sleep -from typing import Container, Optional, Text, Union - -from requests import Response, codes -from six import moves as compat, text_type - -from iota.adapter import BadApiResponse, HttpAdapter, SplitResult -from iota.exceptions import with_context - -__all__ = [ - 'SandboxAdapter', -] - -STATUS_ABORTED = 'ABORTED' -STATUS_FAILED = 'FAILED' -STATUS_FINISHED = 'FINISHED' -STATUS_QUEUED = 'QUEUED' -STATUS_RUNNING = 'RUNNING' - - -class SandboxAdapter(HttpAdapter): - """ - HTTP adapter that sends requests to remote nodes operating in - "sandbox" mode. - - In sandbox mode, the node will only accept authenticated requests - from clients, and certain jobs are completed asynchronously. - - Note: for compatibility with Iota APIs, SandboxAdapter still - operates synchronously; it blocks until it determines that a job has - completed successfully. - - References: - - https://github.com/iotaledger/iota.py/issues/19 - - https://github.com/iotaledger/documentation/blob/sandbox/source/index.html.md - """ - DEFAULT_POLL_INTERVAL = 15 - """ - Number of seconds to wait between requests to check job status. - """ - - DEFAULT_MAX_POLLS = 8 - """ - Maximum number of times to poll for job status before giving up. - """ - - def __init__( - self, - uri, - auth_token, - poll_interval=DEFAULT_POLL_INTERVAL, - max_polls=DEFAULT_MAX_POLLS, - ): - # type: (Union[Text, SplitResult], Optional[Text], int, int) -> None - """ - :param uri: - URI of the node to connect to. - ``https://` URIs are recommended! - - Note: Make sure the URI specifies the correct path! - - Example: - - - Incorrect: ``https://sandbox.iota:14265`` - - Correct: ``https://sandbox.iota:14265/api/v1/`` - - :param auth_token: - Authorization token used to authenticate requests. - - Contact the node's maintainer to obtain a token. - - If ``None``, the adapter will not include authorization - metadata with requests. - - :param poll_interval: - Number of seconds to wait between requests to check job - status. Must be a positive integer. - - Smaller values will cause the adapter to return a result - sooner (once the node completes the job), but it increases - traffic to the node (which may trip a rate limiter and/or - incur additional costs). - - :param max_polls: - Max number of times to poll for job status before giving up. - Must be a positive integer. - - This is effectively a timeout setting for asynchronous jobs; - multiply by ``poll_interval`` to get the timeout duration. - """ - super(SandboxAdapter, self).__init__(uri) - - if not (isinstance(auth_token, text_type) or (auth_token is None)): - raise with_context( - exc=TypeError( - '``auth_token`` must be a unicode string or ``None`` ' - '(``exc.context`` has more info).' - ), - - context={ - 'auth_token': auth_token, - }, - ) - - if auth_token == '': - raise with_context( - exc=ValueError( - 'Set ``auth_token=None`` if requests do not require ' - 'authorization (``exc.context`` has more info).', - ), - - context={ - 'auth_token': auth_token, - }, - ) - - if not isinstance(poll_interval, int): - raise with_context( - exc=TypeError( - '``poll_interval`` must be an int ' - '(``exc.context`` has more info).', - ), - - context={ - 'poll_interval': poll_interval, - }, - ) - - if poll_interval < 1: - raise with_context( - exc=ValueError( - '``poll_interval`` must be > 0 ' - '(``exc.context`` has more info).', - ), - - context={ - 'poll_interval': poll_interval, - }, - ) - - if not isinstance(max_polls, int): - raise with_context( - exc=TypeError( - '``max_polls`` must be an int ' - '(``exc.context`` has more info).', - ), - - context={ - 'max_polls': max_polls, - }, - ) - - if max_polls < 1: - raise with_context( - exc=ValueError( - '``max_polls`` must be > 0 ' - '(``exc.context`` has more info).', - ), - - context={ - 'max_polls': max_polls, - }, - ) - - self.auth_token = auth_token # type: Optional[Text] - self.poll_interval = poll_interval # type: int - self.max_polls = max_polls # type: int - - @property - def node_url(self): - return compat.urllib_parse.urlunsplit(( - self.uri.scheme, - self.uri.netloc, - self.uri.path.rstrip('/') + '/commands', - self.uri.query, - self.uri.fragment, - )) - - @property - def authorization_header(self): - # type: () -> Text - """ - Returns the value to use for the ``Authorization`` header. - """ - return 'token {auth_token}'.format(auth_token=self.auth_token) - - def get_jobs_url(self, job_id): - # type: (Text) -> Text - """ - Returns the URL to check job status. - - :param job_id: - The ID of the job to check. - """ - return compat.urllib_parse.urlunsplit(( - self.uri.scheme, - self.uri.netloc, - self.uri.path.rstrip('/') + '/jobs/' + job_id, - self.uri.query, - self.uri.fragment, - )) - - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - if self.auth_token: - kwargs.setdefault('headers', {}) - kwargs['headers']['Authorization'] = self.authorization_header - - return super(SandboxAdapter, self).send_request(payload, **kwargs) - - def _interpret_response(self, response, payload, expected_status): - # type: (Response, dict, Container[int]) -> dict - decoded = super(SandboxAdapter, self)._interpret_response( - response=response, - payload=payload, - expected_status={codes['ok'], codes['accepted']}, - ) - - # Check to see if the request was queued for asynchronous - # execution. - if response.status_code == codes['accepted']: - poll_count = 0 - while decoded['status'] in (STATUS_QUEUED, STATUS_RUNNING): - if poll_count >= self.max_polls: - raise with_context( - exc=BadApiResponse( - '``{command}`` job timed out after ' - '{duration} seconds ' - '(``exc.context`` has more info).'.format( - command=decoded['command'], - duration=self.poll_interval * self.max_polls, - ), - ), - - context={ - 'request': payload, - 'response': decoded, - }, - ) - - self._wait_to_poll() - poll_count += 1 - - poll_response = self._send_http_request( - headers={'Authorization': self.authorization_header}, - method='get', - payload=None, - url=self.get_jobs_url(decoded['id']), - ) - - decoded = super(SandboxAdapter, self)._interpret_response( - response=poll_response, - payload=payload, - expected_status={codes['ok']}, - ) - - if decoded['status'] == STATUS_FINISHED: - return decoded[ - '{command}Response'.format(command=decoded['command'])] - - raise with_context( - exc=BadApiResponse( - decoded.get('error', {}).get('message') - or 'Command {status}: {decoded}'.format( - decoded=decoded, - status=decoded['status'].lower(), - ), - ), - - context={ - 'request': payload, - 'response': decoded, - }, - ) - - return decoded - - def _wait_to_poll(self): - """ - Waits for 1 interval (according to :py:attr:`poll_interval`). - - Implemented as a separate method so that it can be mocked during - unit tests ("Do you bite your thumb at us, sir?"). - """ - sleep(self.poll_interval) diff --git a/test/adapter/sandbox_test.py b/test/adapter/sandbox_test.py deleted file mode 100644 index 4e377915..00000000 --- a/test/adapter/sandbox_test.py +++ /dev/null @@ -1,414 +0,0 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function, \ - unicode_literals - -import json -from collections import deque -from unittest import TestCase - -from six import text_type - -from iota import BadApiResponse -from iota.adapter import API_VERSION -from iota.adapter.sandbox import SandboxAdapter -from test import mock -from test.adapter_test import create_http_response - - -class SandboxAdapterTestCase(TestCase): - def test_regular_command(self): - """ - Sending a non-sandbox command to the node. - """ - adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') - - expected_result = { - 'message': 'Hello, IOTA!', - } - - mocked_response = create_http_response(json.dumps(expected_result)) - mocked_sender = mock.Mock(return_value=mocked_response) - - payload = {'command': 'helloWorld'} - - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_send_http_request', mocked_sender): - result = adapter.send_request(payload) - - self.assertEqual(result, expected_result) - - mocked_sender.assert_called_once_with( - payload = json.dumps(payload), - url = adapter.node_url, - - # Auth token automatically added to the HTTP request. - headers = { - 'Authorization': 'token ACCESS-TOKEN', - 'Content-type': 'application/json', - 'X-IOTA-API-Version': API_VERSION, - }, - ) - - def test_sandbox_command_succeeds(self): - """ - Sending a sandbox command to the node. - """ - adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') - - expected_result = { - 'message': 'Hello, IOTA!', - } - - # Simulate responses from the node. - responses =\ - deque([ - # The first request creates the job. - # Note that the response has a 202 status. - create_http_response(status=202, content=json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'QUEUED', - 'createdAt': 1483574581, - 'startedAt': None, - 'finishedAt': None, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - })), - - # The job is still running when we poll. - create_http_response(json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'RUNNING', - 'createdAt': 1483574581, - 'startedAt': 1483574589, - 'finishedAt': None, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - })), - - # The job has finished by the next polling request. - create_http_response(json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'FINISHED', - 'createdAt': 1483574581, - 'startedAt': 1483574589, - 'finishedAt': 1483574604, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - - 'helloWorldResponse': expected_result, - })), - ]) - - # noinspection PyUnusedLocal - def _send_http_request(*args, **kwargs): - return responses.popleft() - - mocked_sender = mock.Mock(wraps=_send_http_request) - mocked_waiter = mock.Mock() - - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_send_http_request', mocked_sender): - # mock.Mock ``_wait_to_poll`` so that it returns immediately, instead - # of waiting for 15 seconds. Bad for production, good for tests. - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_wait_to_poll', mocked_waiter): - result = adapter.send_request({'command': 'helloWorld'}) - - self.assertEqual(result, expected_result) - - def test_sandbox_command_fails(self): - """ - A sandbox command fails after an interval. - """ - adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') - - error_message = "You didn't say the magic word!" - - # Simulate responses from the node. - responses =\ - deque([ - # The first request creates the job. - # Note that the response has a 202 status. - create_http_response(status=202, content=json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'QUEUED', - 'createdAt': 1483574581, - 'startedAt': None, - 'finishedAt': None, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - })), - - # The job is still running when we poll. - create_http_response(json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'RUNNING', - 'createdAt': 1483574581, - 'startedAt': 1483574589, - 'finishedAt': None, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - })), - - # The job has finished by the next polling request. - create_http_response(json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'FAILED', - 'createdAt': 1483574581, - 'startedAt': 1483574589, - 'finishedAt': 1483574604, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - - 'error': { - 'message': error_message, - }, - })), - ]) - - # noinspection PyUnusedLocal - def _send_http_request(*args, **kwargs): - return responses.popleft() - - mocked_sender = mock.Mock(wraps=_send_http_request) - mocked_waiter = mock.Mock() - - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_send_http_request', mocked_sender): - # mock.Mock ``_wait_to_poll`` so that it returns immediately, instead - # of waiting for 15 seconds. Bad for production, good for tests. - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_wait_to_poll', mocked_waiter): - with self.assertRaises(BadApiResponse) as context: - adapter.send_request({'command': 'helloWorld'}) - - self.assertEqual(text_type(context.exception), error_message) - - def test_regular_command_null_token(self): - """ - Sending commands to a sandbox that doesn't require authorization. - - This is generally not recommended, but the sandbox node may use - other methods to control access (e.g., listen only on loopback - interface, use IP address whitelist, etc.). - """ - # No access token. - adapter = SandboxAdapter('https://localhost', None) - - expected_result = { - 'message': 'Hello, IOTA!', - } - - mocked_response = create_http_response(json.dumps(expected_result)) - mocked_sender = mock.Mock(return_value=mocked_response) - - payload = {'command': 'helloWorld'} - - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_send_http_request', mocked_sender): - result = adapter.send_request(payload) - - self.assertEqual(result, expected_result) - - mocked_sender.assert_called_once_with( - payload = json.dumps(payload), - url = adapter.node_url, - - headers = { - # No auth token, so no Authorization header. - # 'Authorization': 'token ACCESS-TOKEN', - 'Content-type': 'application/json', - 'X-IOTA-API-Version': API_VERSION, - }, - ) - - def test_error_job_takes_too_long(self): - """ - A job takes too long to complete, and we lose interest. - """ - adapter =\ - SandboxAdapter( - uri = 'https://localhost', - auth_token = 'token', - poll_interval = 15, - max_polls = 2, - ) - - responses =\ - deque([ - # The first request creates the job. - create_http_response(status=202, content=json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'QUEUED', - 'createdAt': 1483574581, - 'startedAt': None, - 'finishedAt': None, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - })), - - # The next two times we poll, the job is still in progress. - create_http_response(json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'RUNNING', - 'createdAt': 1483574581, - 'startedAt': 1483574589, - 'finishedAt': None, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - })), - - create_http_response(json.dumps({ - 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', - 'status': 'RUNNING', - 'createdAt': 1483574581, - 'startedAt': 1483574589, - 'finishedAt': None, - 'command': 'helloWorld', - - 'helloWorldRequest': { - 'command': 'helloWorld', - }, - })), - ]) - - # noinspection PyUnusedLocal - def _send_http_request(*args, **kwargs): - return responses.popleft() - - mocked_sender = mock.Mock(wraps=_send_http_request) - mocked_waiter = mock.Mock() - - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_send_http_request', mocked_sender): - # mock.Mock ``_wait_to_poll`` so that it returns immediately, instead - # of waiting for 15 seconds. Bad for production, good for tests. - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_wait_to_poll', mocked_waiter): - with self.assertRaises(BadApiResponse) as context: - adapter.send_request({'command': 'helloWorld'}) - - self.assertEqual( - text_type(context.exception), - - '``helloWorld`` job timed out after 30 seconds ' - '(``exc.context`` has more info).', - ) - - def test_error_non_200_response(self): - """ - The node sends back a non-200 response. - """ - adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') - - decoded_response = { - 'message': 'You have reached maximum request limit.', - } - - mocked_sender = mock.Mock(return_value=create_http_response( - status = 429, - content = json.dumps(decoded_response), - )) - - # noinspection PyUnresolvedReferences - with mock.patch.object(adapter, '_send_http_request', mocked_sender): - with self.assertRaises(BadApiResponse) as context: - adapter.send_request({'command': 'helloWorld'}) - - self.assertEqual( - text_type(context.exception), - '429 response from node: {decoded}'.format(decoded=decoded_response), - ) - - def test_error_auth_token_wrong_type(self): - """ - ``auth_token`` is not a string. - """ - with self.assertRaises(TypeError): - # Nope; it has to be a unicode string. - # noinspection PyTypeChecker - SandboxAdapter('https://localhost', b'not valid') - - def test_error_auth_token_empty(self): - """ - ``auth_token`` is an empty string. - """ - with self.assertRaises(ValueError): - # If the node does not require authorization, use ``None``. - SandboxAdapter('https://localhost', '') - - def test_error_poll_interval_null(self): - """ - ``poll_interval`` is ``None``. - - The implications of allowing this are cool to think about... - but not implemented yet. - """ - with self.assertRaises(TypeError): - # noinspection PyTypeChecker - SandboxAdapter('https://localhost', 'token', None) - - def test_error_poll_interval_wrong_type(self): - """ - ``poll_interval`` is not an int. - """ - with self.assertRaises(TypeError): - # ``poll_interval`` must be an int. - # noinspection PyTypeChecker - SandboxAdapter('https://localhost', 'token', 42.0) - - def test_error_poll_interval_too_small(self): - """ - ``poll_interval`` is < 1. - """ - with self.assertRaises(ValueError): - SandboxAdapter('https://localhost', 'token', 0) - - def test_error_max_polls_null(self): - """ - ``max_polls`` is None. - """ - with self.assertRaises(TypeError): - # noinspection PyTypeChecker - SandboxAdapter('https://localhost', 'token', max_polls=None) - - def test_max_polls_wrong_type(self): - """ - ``max_polls`` is not an int. - """ - with self.assertRaises(TypeError): - # ``max_polls`` must be an int. - # noinspection PyTypeChecker - SandboxAdapter('https://localhost', 'token', max_polls=2.0) - - def test_max_polls_too_small(self): - """ - ``max_polls`` is < 1. - """ - with self.assertRaises(ValueError): - # noinspection PyTypeChecker - SandboxAdapter('https://localhost', 'token', max_polls=0) diff --git a/test/local_pow_test.py b/test/local_pow_test.py index ab494785..5b462311 100644 --- a/test/local_pow_test.py +++ b/test/local_pow_test.py @@ -4,7 +4,6 @@ from iota import Iota, TryteString, TransactionHash, TransactionTrytes, \ HttpAdapter, MockAdapter -from iota.adapter.sandbox import SandboxAdapter from iota.adapter.wrappers import RoutingWrapper from unittest import TestCase import sys @@ -109,24 +108,6 @@ def test_mock_adapter(self): self.mwm) self.assertTrue(mocked_ccurl.called) self.assertEqual(result['trytes'], self.bundle) - - def test_sandbox_adapter(self): - """ - Test if local_pow feature works with SandboxAdapter. - """ - # Note that we need correct return value to pass the - # response filter. - with patch('pow.ccurl_interface.attach_to_tangle', - MagicMock(return_value=self.bundle)) as mocked_ccurl: - api = Iota(SandboxAdapter('https://sandbox.iota:14265/api/v1/', auth_token=None), - local_pow=True) - result = api.attach_to_tangle( - self.trunk, - self.branch, - self.bundle, - self.mwm) - self.assertTrue(mocked_ccurl.called) - self.assertEqual(result['trytes'], self.bundle) def test_routing_wrapper(self): """ From aaf239149b42644faea8faa485e42a8c7386f64f Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Wed, 11 Dec 2019 12:23:46 +0100 Subject: [PATCH 20/20] Bump version number to 2.2.0b1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f3ac97a5..0943f48c 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ name='PyOTA', description='IOTA API library for Python', url='https://github.com/iotaledger/iota.py', - version='2.1.0', + version='2.2.0b1', long_description=long_description,