From 90f243b5fe881c27dc291a8c5c2f85f1693db5bf Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 23 Jan 2017 20:46:44 -0500 Subject: [PATCH 01/19] Fixed incorrect documentation. --- src/iota/api.py | 75 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/src/iota/api.py b/src/iota/api.py index d37882d..4799bfb 100644 --- a/src/iota/api.py +++ b/src/iota/api.py @@ -4,8 +4,8 @@ from typing import Dict, Iterable, List, Optional, Text -from iota import AdapterSpec, Address, Bundle, ProposedTransaction, Tag, \ - TransactionHash, TryteString, TrytesCompatible +from iota import AdapterSpec, Address, ProposedTransaction, Tag, \ + TransactionHash, TransactionTrytes, TryteString, TrytesCompatible from iota.adapter import BaseAdapter, resolve_adapter from iota.commands import CustomCommand, command_registry from iota.crypto.types import Seed @@ -348,10 +348,19 @@ def __init__(self, adapter, seed=None, testnet=False): self.seed = Seed(seed) if seed else Seed.random() def broadcast_and_store(self, trytes): - # type: (Iterable[TryteString]) -> List[TryteString] + # type: (Iterable[TransactionTrytes]) -> dict """ Broadcasts and stores a set of transaction trytes. + :return: + Dict with the following structure:: + + { + 'trytes': List[TransactionTrytes], + List of TransactionTrytes that were broadcast. + Same as the input ``trytes``. + } + References: - https://github.com/iotaledger/wiki/blob/master/api-proposal.md#broadcastandstore """ @@ -364,14 +373,13 @@ def get_bundles(self, transaction): hash. :param transaction: - Transaction hash. Can be any type of transaction (tail or non- - tail). + Transaction hash. Must be a tail transaction. :return: Dict with the following structure:: { - 'bundles': List[Bundle] + 'bundles': List[Bundle], List of matching bundles. Note that this value is always a list, even if only one bundle was found. } @@ -430,8 +438,12 @@ def get_inputs(self, start=0, stop=None, threshold=None): Dict with the following structure:: { - 'inputs': - 'totalBalance': , + 'inputs': List[Address], + Addresses with nonzero balances that can be used as + inputs. + + 'totalBalance': int, + Aggregate balance from all matching addresses. } Note that each Address in the result has its ``balance`` @@ -470,7 +482,12 @@ def get_latest_inclusion(self, hashes): Iterable of transaction hashes. :return: - {: } + Dict with one boolean per transaction hash in ``hashes``:: + + { + : , + ... + } """ return self.getLatestInclusion(hashes=hashes) @@ -513,7 +530,7 @@ def get_transfers(self, start=0, stop=None, inclusion_states=False): :param stop: Stop before this index. Note that this parameter behaves like the ``stop`` attribute in a - :py:class:`slice` object; the stop index is _not_ included in the + :py:class:`slice` object; the stop index is *not* included in the result. If ``None`` (default), then this method will check every address @@ -529,7 +546,7 @@ def get_transfers(self, start=0, stop=None, inclusion_states=False): Dict containing the following values:: { - 'bundles': List[Bundle] + 'bundles': List[Bundle], Matching bundles, sorted by tail transaction timestamp. } @@ -550,7 +567,8 @@ def prepare_transfer(self, transfers, inputs=None, change_address=None): the correct bundle, as well as choosing and signing the inputs (for value transfers). - :param transfers: Transaction objects to prepare. + :param transfers: + Transaction objects to prepare. :param inputs: List of addresses used to fund the transfer. @@ -571,7 +589,7 @@ def prepare_transfer(self, transfers, inputs=None, change_address=None): Dict containing the following values:: { - 'trytes': List[TryteString] + 'trytes': List[TransactionTrytes], Raw trytes for the transactions in the bundle, ready to be provided to :py:meth:`send_trytes`. } @@ -592,11 +610,11 @@ def replay_bundle( depth, min_weight_magnitude = None, ): - # type: (TransactionHash, int, Optional[int]) -> Bundle + # type: (TransactionHash, int, Optional[int]) -> dict """ 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. + the Tangle. :param transaction: Transaction hash. Must be a tail. @@ -611,7 +629,12 @@ def replay_bundle( If not provided, a default value will be used. :return: - The bundle containing the replayed transfer. + Dict containing the following values:: + + { + 'trytes': List[TransactionTrytes], + Raw trytes that were published to the Tangle. + } References: - https://github.com/iotaledger/wiki/blob/master/api-proposal.md#replaytransfer @@ -633,7 +656,7 @@ def send_transfer( change_address = None, min_weight_magnitude = None, ): - # type: (int, Iterable[ProposedTransaction], Optional[Iterable[Address]], Optional[Address], Optional[int]) -> Bundle + # type: (int, Iterable[ProposedTransaction], Optional[Iterable[Address]], Optional[Address], Optional[int]) -> dict """ Prepares a set of transfers and creates the bundle, then attaches the bundle to the Tangle, and broadcasts and stores the @@ -663,7 +686,12 @@ def send_transfer( If not provided, a default value will be used. :return: - The newly-attached bundle. + Dict containing the following values:: + + { + 'trytes': List[TransactionTrytes], + Raw trytes that were published to the Tangle. + } References: - https://github.com/iotaledger/wiki/blob/master/api-proposal.md#sendtransfer @@ -681,7 +709,7 @@ def send_transfer( ) def send_trytes(self, trytes, depth, min_weight_magnitude=18): - # type: (Iterable[TryteString], int, int) -> List[TryteString] + # type: (Iterable[TransactionTrytes], int, int) -> dict """ Attaches transaction trytes to the Tangle, then broadcasts and stores them. @@ -696,8 +724,15 @@ def send_trytes(self, trytes, depth, min_weight_magnitude=18): Min weight magnitude, used by the node to calibrate Proof of Work. + If not provided, a default value will be used. + :return: - The trytes that were attached to the Tangle. + Dict containing the following values:: + + { + 'trytes': List[TransactionTrytes], + Raw trytes that were published to the Tangle. + } References: - https://github.com/iotaledger/wiki/blob/master/api-proposal.md#sendtrytes From 3a56e50f8aec5bdcc340cc052b0a46a96d525b7d Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Wed, 25 Jan 2017 19:30:23 -0500 Subject: [PATCH 02/19] Fixed RST syntax, reduced cognitive load. --- src/iota/commands/__init__.py | 68 ++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/src/iota/commands/__init__.py b/src/iota/commands/__init__.py index 8101597..55d2a25 100644 --- a/src/iota/commands/__init__.py +++ b/src/iota/commands/__init__.py @@ -22,7 +22,9 @@ ] command_registry = {} # type: Dict[Text, CommandMeta] -"""Registry of commands, indexed by command name.""" +""" +Registry of commands, indexed by command name. +""" def discover_commands(package, recursively=True): @@ -30,9 +32,11 @@ def discover_commands(package, recursively=True): """ Automatically discover commands in the specified package. - :param package: Package path or reference. - :param recursively: If True, will descend recursively into - sub-packages. + :param package: + Package path or reference. + + :param recursively: + If True, will descend recursively into sub-packages. """ # :see: http://stackoverflow.com/a/25562415/ if isinstance(package, string_types): @@ -40,7 +44,7 @@ def discover_commands(package, recursively=True): for _, name, is_package in walk_packages(package.__path__): # Loading the module is good enough; the CommandMeta metaclass will - # ensure that any commands in the module get registered. + # ensure that any commands in the module get registered. sub_package = import_module(package.__name__ + '.' + name) if recursively and is_package: @@ -48,7 +52,9 @@ def discover_commands(package, recursively=True): class CommandMeta(ABCMeta): - """Automatically register new commands.""" + """ + Automatically register new commands. + """ # noinspection PyShadowingBuiltins def __init__(cls, what, bases=None, dict=None): super(CommandMeta, cls).__init__(what, bases, dict) @@ -60,7 +66,9 @@ def __init__(cls, what, bases=None, dict=None): class BaseCommand(with_metaclass(CommandMeta)): - """An API command ready to send to the node.""" + """ + An API command ready to send to the node. + """ command = None # Text def __init__(self, adapter): @@ -77,7 +85,9 @@ def __init__(self, adapter): def __call__(self, **kwargs): # type: (dict) -> dict - """Sends the command to the node.""" + """ + Sends the command to the node. + """ if self.called: raise with_context( exc = RuntimeError('Command has already been called.'), @@ -131,12 +141,13 @@ def _prepare_request(self, request): Modifies the request before sending it to the node. If this method returns a dict, it will replace the request - entirely. + entirely. Note: the `command` parameter will be injected later; it is - not necessary for this method to include it. + not necessary for this method to include it. - :param request: Guaranteed to be a dict, but it might be empty. + :param request: + Guaranteed to be a dict, but it might be empty. """ raise NotImplementedError( 'Not implemented in {cls}.'.format(cls=type(self).__name__), @@ -149,9 +160,10 @@ def _prepare_response(self, response): Modifies the response from the node. If this method returns a dict, it will replace the response - entirely. + entirely. - :param response: Guaranteed to be a dict, but it might be empty. + :param response: + Guaranteed to be a dict, but it might be empty. """ raise NotImplementedError( 'Not implemented in {cls}.'.format(cls=type(self).__name__), @@ -161,7 +173,7 @@ def _prepare_response(self, response): class CustomCommand(BaseCommand): """ Sends an arbitrary command to the node, with no request/response - validation. + validation. Useful for executing experimental/undocumented commands. """ @@ -179,9 +191,11 @@ def _prepare_response(self, response): class RequestFilter(f.FilterChain): - """Template for filter applied to API requests.""" + """ + Template for filter applied to API requests. + """ # Be more strict about missing/extra keys for requests, since they - # tend to come from code that the developer has control over. + # tend to come from code that the developer has control over. def __init__( self, filter_map, @@ -200,9 +214,11 @@ def _apply_none(self): class ResponseFilter(f.FilterChain): - """Template for filter applied to API responses.""" + """ + Template for filter applied to API responses. + """ # Be a little looser about missing/extra keys for responses, since we - # can't control what the node sends us back. + # can't control what the node sends us back. def __init__( self, filter_map, @@ -222,7 +238,9 @@ def _apply_none(self): class FilterCommand(with_metaclass(ABCMeta, BaseCommand)): - """Uses filters to manipulate request/response values.""" + """ + Uses filters to manipulate request/response values. + """ @abstract_method def get_request_filter(self): # type: () -> Optional[RequestFilter] @@ -230,8 +248,8 @@ def get_request_filter(self): Returns the filter that should be applied to the request (if any). Generally, this filter should be strict about validating/converting - the values in the request, to minimize the chance of an error - response from the node. + the values in the request, to minimize the chance of an error + response from the node. """ raise NotImplementedError( 'Not implemented in {cls}.'.format(cls=type(self).__name__), @@ -244,8 +262,8 @@ def get_response_filter(self): Returns the filter that should be applied to the response (if any). Generally, this filter should be less concerned with validation and - more concerned with ensuring the response values have the correct - types, since we can't control what the node sends us. + more concerned with ensuring the response values have the correct + types, since we can't control what the node sends us. """ raise NotImplementedError( 'Not implemented in {cls}.'.format(cls=type(self).__name__), @@ -270,8 +288,8 @@ def _apply_filter(value, filter_, failure_message): # type: (dict, Optional[f.BaseFilter], Text) -> dict """ Applies a filter to a value. If the value does not pass the - filter, an exception will be raised with lots of contextual info - attached to it. + filter, an exception will be raised with lots of contextual info + attached to it. """ if filter_: runner = f.FilterRunner(filter_, value) From f7ce738df2d037b0bf446ac1915a94f0999b0e18 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Wed, 25 Jan 2017 20:10:25 -0500 Subject: [PATCH 03/19] Restrict which commands each API class can create. --- src/iota/api.py | 63 +++++++++++++++++++++++++++++------ src/iota/commands/__init__.py | 21 ++++++++++-- test/api_test.py | 18 +++++++--- 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/iota/api.py b/src/iota/api.py index 4799bfb..5ab58d8 100644 --- a/src/iota/api.py +++ b/src/iota/api.py @@ -4,10 +4,12 @@ from typing import Dict, Iterable, List, Optional, Text +from six import with_metaclass + from iota import AdapterSpec, Address, ProposedTransaction, Tag, \ TransactionHash, TransactionTrytes, TryteString, TrytesCompatible from iota.adapter import BaseAdapter, resolve_adapter -from iota.commands import CustomCommand, command_registry +from iota.commands import BaseCommand, CustomCommand, discover_commands from iota.crypto.types import Seed __all__ = [ @@ -16,7 +18,30 @@ ] -class StrictIota(object): +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 + + +class StrictIota(with_metaclass(ApiMeta)): """ API to send HTTP requests for communicating with an IOTA node. @@ -26,6 +51,8 @@ class StrictIota(object): References: - https://iota.readme.io/docs/getting-started """ + commands = discover_commands('iota.commands.core') + def __init__(self, adapter, testnet=False): # type: (AdapterSpec, bool) -> None """ @@ -44,23 +71,35 @@ def __init__(self, adapter, testnet=False): self.testnet = testnet def __getattr__(self, command): - # type: (Text, dict) -> CustomCommand + # type: (Text) -> BaseCommand """ - Sends an arbitrary API command to the node. + Creates a pre-configured command instance. - This method is useful for invoking undocumented or experimental - methods, or if you just want to troll your node for awhile. + This method will only return commands supported by the API class. + + If you want to execute an arbitrary API command, use + :py:meth:`custom_command`. :param command: - The name of the command to send. + The name of the command to create. References: - https://iota.readme.io/docs/making-requests """ - try: - return command_registry[command](self.adapter) - except KeyError: - return CustomCommand(self.adapter, command) + return self.commands[command](self.adapter) + + def custom_command(self, command): + # type: (Text) -> CustomCommand + """ + Creates a pre-configured CustomCommand instance. + + This method is useful for invoking undocumented or experimental + methods, or if you just want to troll your node for awhile. + + :param command: + The name of the command to create. + """ + return CustomCommand(self.adapter, command) @property def default_min_weight_magnitude(self): @@ -334,6 +373,8 @@ class Iota(StrictIota): - https://iota.readme.io/docs/getting-started - https://github.com/iotaledger/wiki/blob/master/api-proposal.md """ + commands = discover_commands('iota.commands.extended') + def __init__(self, adapter, seed=None, testnet=False): # type: (AdapterSpec, Optional[TrytesCompatible], bool) -> None """ diff --git a/src/iota/commands/__init__.py b/src/iota/commands/__init__.py index 55d2a25..efd26b6 100644 --- a/src/iota/commands/__init__.py +++ b/src/iota/commands/__init__.py @@ -4,7 +4,8 @@ from abc import ABCMeta, abstractmethod as abstract_method from importlib import import_module -from inspect import isabstract as is_abstract +from inspect import isabstract as is_abstract, isclass as is_class, \ + getmembers as get_members from pkgutil import walk_packages from types import ModuleType from typing import Dict, Mapping, Optional, Text, Union @@ -28,7 +29,7 @@ def discover_commands(package, recursively=True): - # type: (Union[ModuleType, Text], bool) -> None + # type: (Union[ModuleType, Text], bool) -> Dict[Text, 'CommandMeta'] """ Automatically discover commands in the specified package. @@ -37,19 +38,33 @@ def discover_commands(package, recursively=True): :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). """ # :see: http://stackoverflow.com/a/25562415/ if isinstance(package, string_types): package = import_module(package) # type: ModuleType + commands = {} + for _, name, is_package in walk_packages(package.__path__): # Loading the module is good enough; the CommandMeta metaclass will # ensure that any commands in the module get registered. sub_package = import_module(package.__name__ + '.' + 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: - discover_commands(sub_package) + commands.update(discover_commands(sub_package)) + return commands class CommandMeta(ABCMeta): """ diff --git a/test/api_test.py b/test/api_test.py index 5b10d85..ad61ea1 100644 --- a/test/api_test.py +++ b/test/api_test.py @@ -115,13 +115,23 @@ def test_registered_command(self): command = api.getNodeInfo self.assertIsInstance(command, GetNodeInfoCommand) + def test_unregistered_command(self): + """ + Attempting to create an unsupported command. + """ + api = StrictIota(MockAdapter()) + + with self.assertRaises(KeyError): + # noinspection PyStatementEffect + api.helloWorld + def test_custom_command(self): """ Preparing an experimental/undocumented command. """ api = StrictIota(MockAdapter()) - # We just need to make sure the correct command type is - # instantiated; custom commands have their own unit tests. - command = api.helloWorld - self.assertIsInstance(command, CustomCommand) + custom_command = api.custom_command('helloWorld') + + self.assertIsInstance(custom_command, CustomCommand) + self.assertEqual(custom_command.command, 'helloWorld') From c3b261d0033962f80e7a246ce3a53fec6d26a88c Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Wed, 25 Jan 2017 21:39:48 -0500 Subject: [PATCH 04/19] [#6,#13] Cleaned up URI handling. - Un-reinvented a wheel. - HttpAdapter no longer accepts `udp://` URIs. - `AddNeighborsCommand` and `RemoveNeighborsCommand` now only accepts `udp://` URIs. --- src/iota/adapter/__init__.py | 140 +++++++++++--------- src/iota/filters.py | 10 +- test/adapter_test.py | 50 +++---- test/commands/core/add_neighbors_test.py | 14 +- test/commands/core/remove_neighbors_test.py | 12 +- 5 files changed, 117 insertions(+), 109 deletions(-) diff --git a/src/iota/adapter/__init__.py b/src/iota/adapter/__init__.py index f3444f8..6f8598e 100644 --- a/src/iota/adapter/__init__.py +++ b/src/iota/adapter/__init__.py @@ -13,7 +13,7 @@ from iota import DEFAULT_PORT from iota.exceptions import with_context from iota.json import JsonEncoder -from six import PY2, binary_type, with_metaclass +from six import PY2, binary_type, moves as compat, text_type, with_metaclass __all__ = [ 'AdapterSpec', @@ -33,6 +33,14 @@ # Custom types for type hints and docstrings. AdapterSpec = Union[Text, 'BaseAdapter'] +# Load SplitResult for IDE type hinting and autocompletion. +if PY2: + # noinspection PyCompatibility,PyUnresolvedReferences + from urlparse import SplitResult +else: + # noinspection PyCompatibility,PyUnresolvedReferences + from urllib.parse import SplitResult + class BadApiResponse(ValueError): """ @@ -48,67 +56,75 @@ class InvalidUri(ValueError): pass -adapter_registry = {} # type: Dict[Text, _AdapterMeta] -"""Keeps track of available adapters and their supported protocols.""" +adapter_registry = {} # type: Dict[Text, AdapterMeta] +""" +Keeps track of available adapters and their supported protocols. +""" def resolve_adapter(uri): # type: (AdapterSpec) -> BaseAdapter - """Given a URI, returns a properly-configured adapter instance.""" + """ + Given a URI, returns a properly-configured adapter instance. + """ if isinstance(uri, BaseAdapter): return uri - try: - protocol, _ = uri.split('://', 1) - except ValueError: + parsed = compat.urllib_parse.urlsplit(uri) # type: SplitResult + + if not parsed.scheme: raise with_context( exc = InvalidUri( 'URI must begin with "://" (e.g., "udp://").', ), context = { - 'uri': uri, + 'parsed': parsed, + 'uri': uri, }, ) try: - adapter_type = adapter_registry[protocol] + adapter_type = adapter_registry[parsed.scheme] except KeyError: raise with_context( exc = InvalidUri('Unrecognized protocol {protocol!r}.'.format( - protocol = protocol, + protocol = parsed.scheme, )), context = { - 'protocol': protocol, - 'uri': uri, + 'parsed': parsed, + 'uri': uri, }, ) - return adapter_type.configure(uri) + return adapter_type.configure(parsed) -class _AdapterMeta(ABCMeta): +class AdapterMeta(ABCMeta): """ Automatically registers new adapter classes in ``adapter_registry``. """ # noinspection PyShadowingBuiltins def __init__(cls, what, bases=None, dict=None): - super(_AdapterMeta, cls).__init__(what, bases, dict) + super(AdapterMeta, cls).__init__(what, bases, dict) if not is_abstract(cls): for protocol in getattr(cls, 'supported_protocols', ()): adapter_registry[protocol] = cls - def configure(cls, uri): - # type: (Text) -> BaseAdapter + def configure(cls, parsed): + # type: (Union[Text, SplitResult]) -> BaseAdapter """ Creates a new adapter from the specified URI. """ - return cls(uri) + if isinstance(parsed, SplitResult): + parsed = parsed.geturl() + + return cls(parsed) -class BaseAdapter(with_metaclass(_AdapterMeta)): +class BaseAdapter(with_metaclass(AdapterMeta)): """ Interface for IOTA API adapters. @@ -148,62 +164,66 @@ class HttpAdapter(BaseAdapter): """ Sends standard HTTP requests. """ - supported_protocols = ('udp', 'http',) + supported_protocols = ('http',) @classmethod - def configure(cls, uri): - # type: (Text) -> HttpAdapter + def configure(cls, parsed): + # type: (Union[Text, SplitResult]) -> HttpAdapter """ Creates a new instance using the specified URI. - :param uri: - E.g., `udp://localhost:14265/` + :param parsed: + Result of :py:func:`urllib.parse.urlsplit`. """ - try: - protocol, config = uri.split('://', 1) - except ValueError: - raise InvalidUri('No protocol specified in URI {uri!r}.'.format(uri=uri)) - else: - if protocol not in cls.supported_protocols: - raise with_context( - exc = InvalidUri('Unsupported protocol {protocol!r}.'.format( - protocol = protocol, - )), - - context = { - 'uri': uri, - }, - ) + if isinstance(parsed, text_type): + parsed = compat.urllib_parse.urlsplit(parsed) # type: SplitResult - try: - server, path = config.split('/', 1) - except ValueError: - server = config - path = '/' - else: - # Restore the '/' delimiter that we used to split the string. - path = '/' + path + if parsed.scheme not in cls.supported_protocols: + raise with_context( + exc = InvalidUri('Unsupported protocol {protocol!r}.'.format( + protocol = parsed.scheme, + )), - try: - host, port = server.split(':', 1) - except ValueError: - host = server + context = { + 'uri': parsed, + }, + ) - if protocol == 'http': - port = 80 - else: - port = DEFAULT_PORT + if not parsed.hostname: + raise with_context( + exc = InvalidUri( + 'Empty hostname in URI {uri!r}.'.format( + uri = parsed.geturl(), + ), + ), - if not host: - raise InvalidUri('Empty hostname in URI {uri!r}.'.format(uri=uri)) + context = { + 'uri': parsed, + }, + ) try: - port = int(port) + port = parsed.port except ValueError: - raise InvalidUri('Non-numeric port in URI {uri!r}.'.format(uri=uri)) + raise with_context( + exc = InvalidUri( + 'Non-numeric port in URI {uri!r}.'.format( + uri = parsed.geturl(), + ), + ), - return cls(host, port, path) + context = { + 'uri': parsed, + }, + ) + + if not port: + if parsed.scheme == 'http': + port = 80 + else: + port = DEFAULT_PORT + return cls(parsed.hostname, port, parsed.path or '/') def __init__(self, host, port=DEFAULT_PORT, path='/'): # type: (Text, int) -> None diff --git a/src/iota/filters.py b/src/iota/filters.py index 274bf61..9e9f495 100644 --- a/src/iota/filters.py +++ b/src/iota/filters.py @@ -2,11 +2,11 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals +from six import moves as compat from typing import Text import filters as f from iota import Address, TryteString, TrytesCompatible -from iota.adapter import resolve_adapter, InvalidUri from six import binary_type, text_type @@ -49,10 +49,10 @@ def _apply(self, value): if self._has_errors: return None - try: - resolve_adapter(value) - except InvalidUri: - return self._invalid_value(value, self.CODE_NOT_NODE_URI, exc_info=True) + parsed = compat.urllib_parse.urlparse(value) + + if parsed.scheme != 'udp': + return self._invalid_value(value, self.CODE_NOT_NODE_URI) return value diff --git a/test/adapter_test.py b/test/adapter_test.py index 4389005..bf48287 100644 --- a/test/adapter_test.py +++ b/test/adapter_test.py @@ -25,13 +25,6 @@ def test_adapter_instance(self): adapter = MockAdapter() self.assertIs(resolve_adapter(adapter), adapter) - def test_udp(self): - """ - Resolving a valid udp:// URI. - """ - adapter = resolve_adapter('udp://localhost:14265/') - self.assertIsInstance(adapter, HttpAdapter) - def test_http(self): """ Resolving a valid http:// URI. @@ -55,16 +48,6 @@ def test_unknown_protocol(self): class HttpAdapterTestCase(TestCase): - def test_configure_udp(self): - """ - Configuring an HttpAdapter using a valid udp:// URI. - """ - adapter = HttpAdapter.configure('udp://localhost:14265/') - - self.assertEqual(adapter.host, 'localhost') - self.assertEqual(adapter.port, 14265) - self.assertEqual(adapter.path, '/') - def test_configure_http(self): """ Configuring HttpAdapter using a valid http:// URI. @@ -79,22 +62,12 @@ def test_configure_ipv4_address(self): """ Configuring an HttpAdapter using an IPv4 address. """ - adapter = HttpAdapter.configure('udp://127.0.0.1:8080/') + adapter = HttpAdapter.configure('http://127.0.0.1:8080/') self.assertEqual(adapter.host, '127.0.0.1') self.assertEqual(adapter.port, 8080) self.assertEqual(adapter.path, '/') - def test_configure_default_port_udp(self): - """ - Implicitly use default UDP port for HttpAdapter. - """ - adapter = HttpAdapter.configure('udp://iotatoken.com/') - - self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, DEFAULT_PORT) - self.assertEqual(adapter.path, '/') - def test_configure_default_port_http(self): """ Implicitly use default HTTP port for HttpAdapter. @@ -109,10 +82,10 @@ def test_configure_path(self): """ Specifying a different path for HttpAdapter. """ - adapter = HttpAdapter.configure('http://iotatoken.com:443/node') + adapter = HttpAdapter.configure('http://iotatoken.com:1024/node') self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, 443) + self.assertEqual(adapter.port, 1024) self.assertEqual(adapter.path, '/node') def test_configure_custom_path_default_port(self): @@ -130,7 +103,7 @@ def test_configure_default_path(self): """ Implicitly use default path for HttpAdapter. """ - adapter = HttpAdapter.configure('udp://example.com:8000') + adapter = HttpAdapter.configure('http://example.com:8000') self.assertEqual(adapter.host, 'example.com') self.assertEqual(adapter.port, 8000) @@ -140,10 +113,10 @@ def test_configure_default_port_and_path(self): """ Implicitly use default port and path for HttpAdapter. """ - adapter = HttpAdapter.configure('udp://localhost') + adapter = HttpAdapter.configure('http://localhost') self.assertEqual(adapter.host, 'localhost') - self.assertEqual(adapter.port, DEFAULT_PORT) + self.assertEqual(adapter.port, 80) self.assertEqual(adapter.path, '/') def test_configure_error_missing_protocol(self): @@ -165,14 +138,21 @@ def test_configure_error_empty_host(self): Attempting to configure HttpAdapter with empty host. """ with self.assertRaises(InvalidUri): - HttpAdapter.configure('udp://:14265') + HttpAdapter.configure('http://:14265') def test_configure_error_non_numeric_port(self): """ Attempting to configure HttpAdapter with non-numeric port. """ with self.assertRaises(InvalidUri): - HttpAdapter.configure('udp://localhost:iota/') + HttpAdapter.configure('http://localhost:iota/') + + def test_configure_error_udp(self): + """ + UDP is not a valid protocol for ``HttpAdapter``. + """ + with self.assertRaises(InvalidUri): + HttpAdapter.configure('udp://localhost:14265') def test_success_response(self): """ diff --git a/test/commands/core/add_neighbors_test.py b/test/commands/core/add_neighbors_test.py index 8d08085..367f878 100644 --- a/test/commands/core/add_neighbors_test.py +++ b/test/commands/core/add_neighbors_test.py @@ -23,7 +23,7 @@ def test_pass_valid_request(self): request = { 'uris': [ 'udp://node1.iotatoken.com', - 'http://localhost:14265/', + 'udp://localhost:14265/', ], } @@ -83,7 +83,7 @@ def test_fail_uris_wrong_type(self): { # Nope; it's gotta be an array, even if you only want to add # a single neighbor. - 'uris': 'http://localhost:8080/' + 'uris': 'udp://localhost:8080/' }, { @@ -116,12 +116,15 @@ def test_fail_uris_contents_invalid(self): '', False, None, - b'http://localhost:8080/', + b'udp://localhost:8080/', 'not a valid uri', # This is actually valid; I just added it to make sure the # filter isn't cheating! - 'udp://localhost', + 'udp://localhost:14265', + + # Only UDP URIs are allowed. + 'http://localhost:14265', 2130706433, ], @@ -133,7 +136,8 @@ def test_fail_uris_contents_invalid(self): 'uris.2': [f.Required.CODE_EMPTY], 'uris.3': [f.Type.CODE_WRONG_TYPE], 'uris.4': [NodeUri.CODE_NOT_NODE_URI], - 'uris.6': [f.Type.CODE_WRONG_TYPE], + 'uris.6': [NodeUri.CODE_NOT_NODE_URI], + 'uris.7': [f.Type.CODE_WRONG_TYPE], }, ) diff --git a/test/commands/core/remove_neighbors_test.py b/test/commands/core/remove_neighbors_test.py index 90a7134..b05c532 100644 --- a/test/commands/core/remove_neighbors_test.py +++ b/test/commands/core/remove_neighbors_test.py @@ -23,7 +23,7 @@ def test_pass_valid_request(self): request = { 'uris': [ 'udp://node1.iotatoken.com', - 'http://localhost:14265/', + 'udp://localhost:14265/', ], } @@ -83,7 +83,7 @@ def test_fail_uris_wrong_type(self): { # Nope; it's gotta be an array, even if you only want to add # a single neighbor. - 'uris': 'http://localhost:8080/' + 'uris': 'udp://localhost:8080/' }, { @@ -118,13 +118,16 @@ def test_fail_uris_contents_invalid(self): '', False, None, - b'http://localhost:8080/', + b'udp://localhost:8080/', 'not a valid uri', # This is actually valid; I just added it to make sure the # filter isn't cheating! 'udp://localhost', + # Only UDP URIs are allowed. + 'http://localhost:14265', + 2130706433, ], }, @@ -135,7 +138,8 @@ def test_fail_uris_contents_invalid(self): 'uris.2': [f.Required.CODE_EMPTY], 'uris.3': [f.Type.CODE_WRONG_TYPE], 'uris.4': [NodeUri.CODE_NOT_NODE_URI], - 'uris.6': [f.Type.CODE_WRONG_TYPE], + 'uris.6': [NodeUri.CODE_NOT_NODE_URI], + 'uris.7': [f.Type.CODE_WRONG_TYPE], }, ) From 5661917237b8c638e8510bcaf8a71ee008c7b68b Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Wed, 25 Jan 2017 21:54:26 -0500 Subject: [PATCH 05/19] `getNewAddresses` now returns a dict. Return types for all API commands are now consistent. --- src/iota/api.py | 10 ++++++---- .../commands/extended/get_new_addresses.py | 14 ++++++++++++-- src/iota/commands/extended/prepare_transfer.py | 3 ++- .../extended/get_new_addresses_test.py | 4 ++-- .../commands/extended/prepare_transfer_test.py | 18 ++++++++++-------- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/iota/api.py b/src/iota/api.py index 5ab58d8..89a8ee5 100644 --- a/src/iota/api.py +++ b/src/iota/api.py @@ -533,7 +533,7 @@ def get_latest_inclusion(self, hashes): return self.getLatestInclusion(hashes=hashes) def get_new_addresses(self, index=0, count=1): - # type: (int, Optional[int]) -> List[Address] + # type: (int, Optional[int]) -> dict """ Generates one or more new addresses from the seed. @@ -550,10 +550,12 @@ def get_new_addresses(self, index=0, count=1): available unused address and return that. :return: - List of generated addresses. + Dict with the following items:: - Note that this method always returns a list, even if only one - address is generated. + { + 'addresses': List[Address], + Always a list, even if only one address was generated. + } References: - https://github.com/iotaledger/wiki/blob/master/api-proposal.md#getnewaddress diff --git a/src/iota/commands/extended/get_new_addresses.py b/src/iota/commands/extended/get_new_addresses.py index 75192ec..a4ca565 100644 --- a/src/iota/commands/extended/get_new_addresses.py +++ b/src/iota/commands/extended/get_new_addresses.py @@ -2,10 +2,11 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from typing import Optional +from typing import List, Optional import filters as f +from iota import Address from iota.commands import FilterCommand, RequestFilter from iota.commands.core.find_transactions import FindTransactionsCommand from iota.crypto.addresses import AddressGenerator @@ -34,8 +35,17 @@ def get_response_filter(self): def _execute(self, request): count = request['count'] # type: Optional[int] index = request['index'] # type: int - seed = request['seed'] # type: Seed + seed = request['seed'] # type: Seed + return { + 'addresses': self._find_addresses(seed, index, count), + } + + def _find_addresses(self, seed, index, count): + """ + Find addresses matching the command parameters. + """ + # type: (Seed, int, Optional[int]) -> List[Address] generator = AddressGenerator(seed) if count is None: diff --git a/src/iota/commands/extended/prepare_transfer.py b/src/iota/commands/extended/prepare_transfer.py index 3825385..cab9c7c 100644 --- a/src/iota/commands/extended/prepare_transfer.py +++ b/src/iota/commands/extended/prepare_transfer.py @@ -98,7 +98,8 @@ def _execute(self, request): if bundle.balance < 0: if not change_address: - change_address = GetNewAddressesCommand(self.adapter)(seed=seed)[0] + change_address =\ + GetNewAddressesCommand(self.adapter)(seed=seed)['addresses'][0] bundle.send_unspent_inputs_to(change_address) diff --git a/test/commands/extended/get_new_addresses_test.py b/test/commands/extended/get_new_addresses_test.py index e1131f0..337678b 100644 --- a/test/commands/extended/get_new_addresses_test.py +++ b/test/commands/extended/get_new_addresses_test.py @@ -311,7 +311,7 @@ def create_generator(ag, start, step=1): seed = b'TESTSEED9DONTUSEINPRODUCTION99999', ) - self.assertListEqual(response, [self.addy1, self.addy2]) + self.assertDictEqual(response, {'addresses': [self.addy1, self.addy2]}) # No API requests were made. self.assertListEqual(self.adapter.requests, []) @@ -359,7 +359,7 @@ def create_generator(ag, start, step=1): # The command determined that ``self.addy1`` was already used, so # it skipped that one. - self.assertListEqual(response, [self.addy2]) + self.assertDictEqual(response, {'addresses': [self.addy2]}) self.assertListEqual( self.adapter.requests, diff --git a/test/commands/extended/prepare_transfer_test.py b/test/commands/extended/prepare_transfer_test.py index b01b3cf..20d3f80 100644 --- a/test/commands/extended/prepare_transfer_test.py +++ b/test/commands/extended/prepare_transfer_test.py @@ -2176,15 +2176,17 @@ def test_pass_change_address_auto_generated(self): # References: # - :py:class:`iota.commands.extended.prepare_transfer.PrepareTransferCommand` # - :py:class:`iota.commands.extended.get_new_addresses.GetNewAddressesCommand` - mock_get_new_addresses_command = Mock(return_value=[ - Address( - trytes = - b'TESTVALUEFOUR9DONTUSEINPRODUCTION99999WJ' - b'RBOSBIMNTGDYKUDYYFJFGZOHORYSQPCWJRKHIOVIY', + mock_get_new_addresses_command = Mock(return_value={ + 'addresses': [ + Address( + trytes = + b'TESTVALUEFOUR9DONTUSEINPRODUCTION99999WJ' + b'RBOSBIMNTGDYKUDYYFJFGZOHORYSQPCWJRKHIOVIY', - key_index = 5, - ), - ]) + key_index = 5, + ), + ], + }) self.adapter.seed_response('getBalances', { 'balances': [86], From 3c79fa39bd6ef04821cc5fa2c3da0836e038a2a3 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Wed, 25 Jan 2017 22:17:31 -0500 Subject: [PATCH 06/19] [#7] Added support for `https://` URIs. --- examples/shell.py | 33 +++-------- src/iota/adapter/__init__.py | 69 ++++++++--------------- test/adapter/wrappers_test.py | 2 +- test/adapter_test.py | 100 +++++++++++----------------------- 4 files changed, 64 insertions(+), 140 deletions(-) diff --git a/examples/shell.py b/examples/shell.py index acfb10b..487354f 100644 --- a/examples/shell.py +++ b/examples/shell.py @@ -34,16 +34,21 @@ def main(uri, testnet, pow_uri, debug_requests): if isinstance(seed, text_type): seed = seed.encode('ascii') + adapter_ = resolve_adapter(uri) + # If ``pow_uri`` is specified, route POW requests to a separate node. - adapter_ = create_adapter(uri, debug_requests) if pow_uri: - pow_adapter = create_adapter(pow_uri, debug_requests) + pow_adapter = resolve_adapter(pow_uri) adapter_ =\ RoutingWrapper(adapter_)\ .add_route('attachToTangle', pow_adapter)\ .add_route('interruptAttachingToTangle', pow_adapter) + # If ``debug_requests`` is specified, log HTTP requests/responses. + if debug_requests: + adapter_ = LogWrapper(adapter_, getLogger(__name__), INFO) + iota = Iota(adapter_, seed=seed, testnet=testnet) _banner = ( @@ -57,26 +62,6 @@ def main(uri, testnet, pow_uri, debug_requests): start_shell(iota, _banner) -def create_adapter(uri, debug): - # type: (Text, bool) -> BaseAdapter - """ - Creates an adapter with the specified settings. - - :param uri: - Node URI. - - :param debug: - Whether to attach a LogWrapper to the adapter. - """ - adapter_ = resolve_adapter(uri) - - return ( - LogWrapper(adapter_, getLogger(__name__), INFO) - if debug - else adapter_ - ) - - def start_shell(iota, _banner): """ Starts the shell with limited scope. @@ -102,11 +87,11 @@ def start_shell(iota, _banner): parser.add_argument( '--uri', type = text_type, - default = 'udp://localhost:14265/', + default = 'http://localhost:14265/', help = 'URI of the node to connect to ' - '(defaults to udp://localhost:14265/).', + '(defaults to http://localhost:14265/).', ) parser.add_argument( diff --git a/src/iota/adapter/__init__.py b/src/iota/adapter/__init__.py index 6f8598e..c867a71 100644 --- a/src/iota/adapter/__init__.py +++ b/src/iota/adapter/__init__.py @@ -10,7 +10,6 @@ from typing import Dict, List, Text, Tuple, Union import requests -from iota import DEFAULT_PORT from iota.exceptions import with_context from iota.json import JsonEncoder from six import PY2, binary_type, moves as compat, text_type, with_metaclass @@ -114,13 +113,13 @@ def __init__(cls, what, bases=None, dict=None): adapter_registry[protocol] = cls def configure(cls, parsed): - # type: (Union[Text, SplitResult]) -> BaseAdapter - """ - Creates a new adapter from the specified URI. + # type: (Union[Text, SplitResult]) -> HttpAdapter """ - if isinstance(parsed, SplitResult): - parsed = parsed.geturl() + Creates a new instance using the specified URI. + :param parsed: + Result of :py:func:`urllib.parse.urlsplit`. + """ return cls(parsed) @@ -164,74 +163,56 @@ class HttpAdapter(BaseAdapter): """ Sends standard HTTP requests. """ - supported_protocols = ('http',) + supported_protocols = ('http', 'https',) - @classmethod - def configure(cls, parsed): - # type: (Union[Text, SplitResult]) -> HttpAdapter - """ - Creates a new instance using the specified URI. + def __init__(self, uri): + # type: (Union[Text, SplitResult]) -> None + super(HttpAdapter, self).__init__() - :param parsed: - Result of :py:func:`urllib.parse.urlsplit`. - """ - if isinstance(parsed, text_type): - parsed = compat.urllib_parse.urlsplit(parsed) # type: SplitResult + if isinstance(uri, text_type): + uri = compat.urllib_parse.urlsplit(uri) # type: SplitResult - if parsed.scheme not in cls.supported_protocols: + if uri.scheme not in self.supported_protocols: raise with_context( exc = InvalidUri('Unsupported protocol {protocol!r}.'.format( - protocol = parsed.scheme, + protocol = uri.scheme, )), context = { - 'uri': parsed, + 'uri': uri, }, ) - if not parsed.hostname: + if not uri.hostname: raise with_context( exc = InvalidUri( 'Empty hostname in URI {uri!r}.'.format( - uri = parsed.geturl(), + uri = uri.geturl(), ), ), context = { - 'uri': parsed, + 'uri': uri, }, ) try: - port = parsed.port + # noinspection PyStatementEffect + uri.port except ValueError: raise with_context( exc = InvalidUri( 'Non-numeric port in URI {uri!r}.'.format( - uri = parsed.geturl(), + uri = uri.geturl(), ), ), context = { - 'uri': parsed, + 'uri': uri, }, ) - if not port: - if parsed.scheme == 'http': - port = 80 - else: - port = DEFAULT_PORT - - return cls(parsed.hostname, port, parsed.path or '/') - - def __init__(self, host, port=DEFAULT_PORT, path='/'): - # type: (Text, int) -> None - super(HttpAdapter, self).__init__() - - self.host = host - self.port = port - self.path = path + self.uri = uri @property def node_url(self): @@ -239,11 +220,7 @@ def node_url(self): """ Returns the node URL. """ - return 'http://{host}:{port}{path}'.format( - host = self.host, - port = self.port, - path = self.path, - ) + return self.uri.geturl() def send_request(self, payload, **kwargs): # type: (dict, dict) -> dict diff --git a/test/adapter/wrappers_test.py b/test/adapter/wrappers_test.py index bbc2c30..f8ec7e5 100644 --- a/test/adapter/wrappers_test.py +++ b/test/adapter/wrappers_test.py @@ -69,7 +69,7 @@ def test_router_aliasing(self): ) # Providing an adapter instance bypasses the whole setup. - wrapper1.add_route('delta', HttpAdapter('localhost', 14265)) + wrapper1.add_route('delta', HttpAdapter('http://localhost:14265')) self.assertIsNot( wrapper1.get_adapter('delta'), wrapper1.get_adapter('alpha'), diff --git a/test/adapter_test.py b/test/adapter_test.py index bf48287..e9b8df1 100644 --- a/test/adapter_test.py +++ b/test/adapter_test.py @@ -7,12 +7,11 @@ from unittest import TestCase import requests +from iota import BadApiResponse, InvalidUri, TryteString +from iota.adapter import HttpAdapter, MockAdapter, resolve_adapter from mock import Mock, patch from six import BytesIO, text_type as text -from iota import BadApiResponse, DEFAULT_PORT, InvalidUri, TryteString -from iota.adapter import HttpAdapter, MockAdapter, resolve_adapter - class ResolveAdapterTestCase(TestCase): """ @@ -27,11 +26,18 @@ def test_adapter_instance(self): def test_http(self): """ - Resolving a valid http:// URI. + Resolving a valid `http://` URI. """ adapter = resolve_adapter('http://localhost:14265/') self.assertIsInstance(adapter, HttpAdapter) + def test_https(self): + """ + Resolving a valid `https://` URI. + """ + adapter = resolve_adapter('https://localhost:14265/') + self.assertIsInstance(adapter, HttpAdapter) + def test_missing_protocol(self): """ The URI does not include a protocol. @@ -48,76 +54,32 @@ def test_unknown_protocol(self): class HttpAdapterTestCase(TestCase): - def test_configure_http(self): + def test_http(self): """ Configuring HttpAdapter using a valid http:// URI. """ - adapter = HttpAdapter.configure('http://localhost:14265/') - - self.assertEqual(adapter.host, 'localhost') - self.assertEqual(adapter.port, 14265) - self.assertEqual(adapter.path, '/') - - def test_configure_ipv4_address(self): - """ - Configuring an HttpAdapter using an IPv4 address. - """ - adapter = HttpAdapter.configure('http://127.0.0.1:8080/') + uri = 'http://localhost:14265/' + adapter = HttpAdapter(uri) - self.assertEqual(adapter.host, '127.0.0.1') - self.assertEqual(adapter.port, 8080) - self.assertEqual(adapter.path, '/') + self.assertEqual(adapter.node_url, uri) - def test_configure_default_port_http(self): + def test_https(self): """ - Implicitly use default HTTP port for HttpAdapter. + Configuring HttpAdapter using a valid https:// URI. """ - adapter = HttpAdapter.configure('http://iotatoken.com/') + uri = 'https://localhost:14265/' + adapter = HttpAdapter(uri) - self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, 80) - self.assertEqual(adapter.path, '/') + self.assertEqual(adapter.node_url, uri) - def test_configure_path(self): + def test_ipv4_address(self): """ - Specifying a different path for HttpAdapter. - """ - adapter = HttpAdapter.configure('http://iotatoken.com:1024/node') - - self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, 1024) - self.assertEqual(adapter.path, '/node') - - def test_configure_custom_path_default_port(self): - """ - Configuring HttpAdapter to use a custom path but implicitly use - default port. - """ - adapter = HttpAdapter.configure('http://iotatoken.com/node') - - self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, 80) - self.assertEqual(adapter.path, '/node') - - def test_configure_default_path(self): - """ - Implicitly use default path for HttpAdapter. - """ - adapter = HttpAdapter.configure('http://example.com:8000') - - self.assertEqual(adapter.host, 'example.com') - self.assertEqual(adapter.port, 8000) - self.assertEqual(adapter.path, '/') - - def test_configure_default_port_and_path(self): - """ - Implicitly use default port and path for HttpAdapter. + Configuring an HttpAdapter using an IPv4 address. """ - adapter = HttpAdapter.configure('http://localhost') + uri = 'http://127.0.0.1:8080/' + adapter = HttpAdapter(uri) - self.assertEqual(adapter.host, 'localhost') - self.assertEqual(adapter.port, 80) - self.assertEqual(adapter.path, '/') + self.assertEqual(adapter.node_url, uri) def test_configure_error_missing_protocol(self): """ @@ -159,7 +121,7 @@ def test_success_response(self): Simulates sending a command to the node and getting a success response. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') expected_result = { 'message': 'Hello, IOTA!', @@ -179,7 +141,7 @@ def test_error_response(self): Simulates sending a command to the node and getting an error response. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') expected_result = 'Command \u0027helloWorld\u0027 is unknown' @@ -202,7 +164,7 @@ def test_exception_response(self): Simulates sending a command to the node and getting an exception response. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') expected_result = 'java.lang.ArrayIndexOutOfBoundsException: 4' @@ -224,7 +186,7 @@ def test_empty_response(self): """ The response is empty. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') mocked_response = self._create_response('') @@ -241,7 +203,7 @@ def test_non_json_response(self): """ The response is not JSON. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') invalid_response = 'EHLO iotatoken.com' # Erm... mocked_response = self._create_response(invalid_response) @@ -262,7 +224,7 @@ def test_non_object_response(self): """ The response is valid JSON, but it's not an object. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') invalid_response = '["message", "Hello, IOTA!"]' mocked_response = self._create_response(invalid_response) @@ -284,7 +246,7 @@ def test_trytes_in_request(self): """ Sending a request that includes trytes. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') # Response is not important for this test; we just need to make # sure that the request is converted correctly. From 72de4b079223733217dfc1c4483fb01c487f8dce Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Wed, 25 Jan 2017 22:32:34 -0500 Subject: [PATCH 07/19] Better exception type, method name. --- src/iota/api.py | 29 +++++++++++++++++++++++------ test/api_test.py | 8 ++++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/iota/api.py b/src/iota/api.py index 89a8ee5..f92ed62 100644 --- a/src/iota/api.py +++ b/src/iota/api.py @@ -2,22 +2,29 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from typing import Dict, Iterable, List, Optional, Text - -from six import with_metaclass +from typing import Dict, Iterable, Optional, Text from iota import AdapterSpec, Address, ProposedTransaction, Tag, \ TransactionHash, TransactionTrytes, TryteString, TrytesCompatible from iota.adapter import BaseAdapter, resolve_adapter from iota.commands import BaseCommand, CustomCommand, discover_commands from iota.crypto.types import Seed +from six import with_metaclass __all__ = [ + 'InvalidCommand', 'Iota', 'StrictIota', ] +class InvalidCommand(ValueError): + """ + Indicates that an invalid command name was specified. + """ + pass + + class ApiMeta(type): """ Manages command registries for IOTA API base classes. @@ -78,7 +85,7 @@ def __getattr__(self, command): This method will only return commands supported by the API class. If you want to execute an arbitrary API command, use - :py:meth:`custom_command`. + :py:meth:`create_command`. :param command: The name of the command to create. @@ -86,9 +93,19 @@ def __getattr__(self, command): References: - https://iota.readme.io/docs/making-requests """ - return self.commands[command](self.adapter) + 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 custom_command(self, command): + def create_command(self, command): # type: (Text) -> CustomCommand """ Creates a pre-configured CustomCommand instance. diff --git a/test/api_test.py b/test/api_test.py index ad61ea1..ae2569e 100644 --- a/test/api_test.py +++ b/test/api_test.py @@ -4,7 +4,7 @@ from unittest import TestCase -from iota import StrictIota +from iota import InvalidCommand, StrictIota from iota.adapter import MockAdapter from iota.commands import CustomCommand from iota.commands.core.get_node_info import GetNodeInfoCommand @@ -121,17 +121,17 @@ def test_unregistered_command(self): """ api = StrictIota(MockAdapter()) - with self.assertRaises(KeyError): + with self.assertRaises(InvalidCommand): # noinspection PyStatementEffect api.helloWorld - def test_custom_command(self): + def test_create_command(self): """ Preparing an experimental/undocumented command. """ api = StrictIota(MockAdapter()) - custom_command = api.custom_command('helloWorld') + custom_command = api.create_command('helloWorld') self.assertIsInstance(custom_command, CustomCommand) self.assertEqual(custom_command.command, 'helloWorld') From e1ff59e9ce15fd6874316de968c355557606cc55 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 10:05:14 -0500 Subject: [PATCH 08/19] Better security for https connections. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 497d6e3..45c5528 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ install_requires = [ 'filters', - 'requests', + 'requests[security]', 'six', 'typing', ], From a941c3da89a9e88296059b34dac0a14d8f86c3aa Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 11:03:15 -0500 Subject: [PATCH 09/19] [#19] Stubbed-out `SandboxAdapter`. --- src/iota/adapter/sandbox.py | 120 +++++++++++++++++++++++++++++++++++ test/adapter/sandbox_test.py | 77 ++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/iota/adapter/sandbox.py create mode 100644 test/adapter/sandbox_test.py diff --git a/src/iota/adapter/sandbox.py b/src/iota/adapter/sandbox.py new file mode 100644 index 0000000..1c99ce6 --- /dev/null +++ b/src/iota/adapter/sandbox.py @@ -0,0 +1,120 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from typing import Optional, Text, Union + +from six import text_type + +from iota.adapter import HttpAdapter, SplitResult +from iota.exceptions import with_context + + +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.lib.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. + """ + + def __init__(self, uri, token, poll_interval=DEFAULT_POLL_INTERVAL): + # type: (Union[Text, SplitResult], Optional[Text], 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 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). + """ + super(SandboxAdapter, self).__init__(uri) + + if not (isinstance(token, text_type) or (token is None)): + raise with_context( + exc = + TypeError( + '``token`` must be a unicode string or ``None`` ' + '(``exc.context`` has more info).' + ), + + context = { + 'token': token, + }, + ) + + if token == '': + raise with_context( + exc = + ValueError( + 'Set ``token=None`` if requests do not require authorization ' + '(``exc.context`` has more info).', + ), + + context = { + 'token': 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 >= 1 ' + '(``exc.context`` has more info).', + ), + + context = { + 'poll_interval': poll_interval, + }, + ) + + self.token = token # type: Optional[Text] + self.poll_interval = poll_interval # type: int diff --git a/test/adapter/sandbox_test.py b/test/adapter/sandbox_test.py new file mode 100644 index 0000000..31bdf94 --- /dev/null +++ b/test/adapter/sandbox_test.py @@ -0,0 +1,77 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from unittest import TestCase + +from iota.adapter.sandbox import SandboxAdapter + + +class SandboxAdapterTestCase(TestCase): + def test_regular_command(self): + """ + Sending a non-sandbox command to the node. + """ + # :todo: Implement test. + self.skipTest('Not implemented yet.') + + def test_sandbox_command(self): + """ + Sending a sandbox command to the node. + """ + # :todo: Implement test. + self.skipTest('Not implemented yet.') + + 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.). + """ + # :todo: Implement test. + self.skipTest('Not implemented yet.') + + def test_error_token_wrong_type(self): + """ + ``token`` is not a string. + """ + with self.assertRaises(TypeError): + # Nope; it has to be a unicode string. + SandboxAdapter('https://localhost', b'not valid') + + def test_error_token_empty(self): + """ + ``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 or float. + """ + 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) From d7532f6863b923733e9cb55bf469971c17ebf282 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 11:20:59 -0500 Subject: [PATCH 10/19] [#19] Add auth token to sandbox requests. --- src/iota/adapter/sandbox.py | 30 ++++++++++++++------ test/adapter/sandbox_test.py | 31 ++++++++++++++++++-- test/adapter_test.py | 55 +++++++++++++++++++----------------- 3 files changed, 79 insertions(+), 37 deletions(-) diff --git a/src/iota/adapter/sandbox.py b/src/iota/adapter/sandbox.py index 1c99ce6..9eb3f4d 100644 --- a/src/iota/adapter/sandbox.py +++ b/src/iota/adapter/sandbox.py @@ -31,7 +31,7 @@ class SandboxAdapter(HttpAdapter): Number of seconds to wait between requests to check job status. """ - def __init__(self, uri, token, poll_interval=DEFAULT_POLL_INTERVAL): + def __init__(self, uri, auth_token, poll_interval=DEFAULT_POLL_INTERVAL): # type: (Union[Text, SplitResult], Optional[Text], int) -> None """ :param uri: @@ -45,7 +45,7 @@ def __init__(self, uri, token, poll_interval=DEFAULT_POLL_INTERVAL): - Incorrect: ``https://sandbox.iota:14265`` - Correct: ``https://sandbox.iota:14265/api/v1/`` - :param token: + :param auth_token: Authorization token used to authenticate requests. Contact the node's maintainer to obtain a token. @@ -64,29 +64,29 @@ def __init__(self, uri, token, poll_interval=DEFAULT_POLL_INTERVAL): """ super(SandboxAdapter, self).__init__(uri) - if not (isinstance(token, text_type) or (token is None)): + if not (isinstance(auth_token, text_type) or (auth_token is None)): raise with_context( exc = TypeError( - '``token`` must be a unicode string or ``None`` ' + '``auth_token`` must be a unicode string or ``None`` ' '(``exc.context`` has more info).' ), context = { - 'token': token, + 'auth_token': auth_token, }, ) - if token == '': + if auth_token == '': raise with_context( exc = ValueError( - 'Set ``token=None`` if requests do not require authorization ' + 'Set ``auth_token=None`` if requests do not require authorization ' '(``exc.context`` has more info).', ), context = { - 'token': token, + 'auth_token': auth_token, }, ) @@ -116,5 +116,17 @@ def __init__(self, uri, token, poll_interval=DEFAULT_POLL_INTERVAL): }, ) - self.token = token # type: Optional[Text] + self.auth_token = auth_token # type: Optional[Text] self.poll_interval = poll_interval # type: int + + def send_request(self, payload, **kwargs): + # type: (dict, dict) -> dict + if self.auth_token: + kwargs.setdefault('headers', {}) + + kwargs['headers']['Authorization'] =\ + 'token {token}'.format( + token = self.auth_token, + ) + + return super(SandboxAdapter, self).send_request(payload, **kwargs) diff --git a/test/adapter/sandbox_test.py b/test/adapter/sandbox_test.py index 31bdf94..6b8c967 100644 --- a/test/adapter/sandbox_test.py +++ b/test/adapter/sandbox_test.py @@ -2,9 +2,13 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals +import json from unittest import TestCase +from mock import Mock, patch + from iota.adapter.sandbox import SandboxAdapter +from test.adapter_test import create_http_response class SandboxAdapterTestCase(TestCase): @@ -12,8 +16,31 @@ def test_regular_command(self): """ Sending a non-sandbox command to the node. """ - # :todo: Implement test. - self.skipTest('Not implemented yet.') + adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') + + expected_result = { + 'message': 'Hello, IOTA!', + } + + mocked_response = create_http_response(json.dumps(expected_result)) + mocked_sender = Mock(return_value=mocked_response) + + payload = {'command': 'helloWorld'} + + # noinspection PyUnresolvedReferences + with 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), + + # Auth token automatically added to the HTTP request. + headers = { + 'Authorization': 'token ACCESS-TOKEN', + }, + ) def test_sandbox_command(self): """ diff --git a/test/adapter_test.py b/test/adapter_test.py index e9b8df1..f54a2ee 100644 --- a/test/adapter_test.py +++ b/test/adapter_test.py @@ -53,6 +53,26 @@ def test_unknown_protocol(self): resolve_adapter('foobar://localhost:14265') +def create_http_response(content): + # type: (Text) -> requests.Response + """ + Creates an HTTP Response object for a test. + """ + # :py:meth:`requests.adapters.HTTPAdapter.build_response` + response = requests.Response() + + # Response status is always 200, even for an error. + # Note that this is fixed in later IRI implementations, but for + # backwards-compatibility, the adapter will ignore the status code. + # https://github.com/iotaledger/iri/issues/9 + response.status_code = 200 + + response.encoding = 'utf-8' + response.raw = BytesIO(content.encode('utf-8')) + + return response + + class HttpAdapterTestCase(TestCase): def test_http(self): """ @@ -127,7 +147,7 @@ def test_success_response(self): 'message': 'Hello, IOTA!', } - mocked_response = self._create_response(json.dumps(expected_result)) + mocked_response = create_http_response(json.dumps(expected_result)) mocked_sender = Mock(return_value=mocked_response) # noinspection PyUnresolvedReferences @@ -145,7 +165,7 @@ def test_error_response(self): expected_result = 'Command \u0027helloWorld\u0027 is unknown' - mocked_response = self._create_response(json.dumps({ + mocked_response = create_http_response(json.dumps({ 'error': expected_result, 'duration': 42, })) @@ -168,7 +188,7 @@ def test_exception_response(self): expected_result = 'java.lang.ArrayIndexOutOfBoundsException: 4' - mocked_response = self._create_response(json.dumps({ + mocked_response = create_http_response(json.dumps({ 'exception': 'java.lang.ArrayIndexOutOfBoundsException: 4', 'duration': 16 })) @@ -188,7 +208,7 @@ def test_empty_response(self): """ adapter = HttpAdapter('http://localhost:14265') - mocked_response = self._create_response('') + mocked_response = create_http_response('') mocked_sender = Mock(return_value=mocked_response) @@ -206,7 +226,7 @@ def test_non_json_response(self): adapter = HttpAdapter('http://localhost:14265') invalid_response = 'EHLO iotatoken.com' # Erm... - mocked_response = self._create_response(invalid_response) + mocked_response = create_http_response(invalid_response) mocked_sender = Mock(return_value=mocked_response) @@ -227,7 +247,7 @@ def test_non_object_response(self): adapter = HttpAdapter('http://localhost:14265') invalid_response = '["message", "Hello, IOTA!"]' - mocked_response = self._create_response(invalid_response) + mocked_response = create_http_response(invalid_response) mocked_sender = Mock(return_value=mocked_response) @@ -242,7 +262,8 @@ def test_non_object_response(self): ) # noinspection SpellCheckingInspection - def test_trytes_in_request(self): + @staticmethod + def test_trytes_in_request(): """ Sending a request that includes trytes. """ @@ -250,7 +271,7 @@ def test_trytes_in_request(self): # Response is not important for this test; we just need to make # sure that the request is converted correctly. - mocked_sender = Mock(return_value=self._create_response('{}')) + mocked_sender = Mock(return_value=create_http_response('{}')) # noinspection PyUnresolvedReferences with patch.object(adapter, '_send_http_request', mocked_sender): @@ -276,21 +297,3 @@ def test_trytes_in_request(self): ], }), ) - - @staticmethod - def _create_response(content): - # type: (Text) -> requests.Response - """ - Creates a Response object for a test. - """ - # :py:meth:`requests.adapters.HTTPAdapter.build_response` - response = requests.Response() - - # Response status is always 200, even for an error. - # https://github.com/iotaledger/iri/issues/9 - response.status_code = 200 - - response.encoding = 'utf-8' - response.raw = BytesIO(content.encode('utf-8')) - - return response From 5eff7cf009bc85577623b259344f0683a9a06d96 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 11:22:49 -0500 Subject: [PATCH 11/19] [#19] Doc'd behavior of null auth_token. --- test/adapter/sandbox_test.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/test/adapter/sandbox_test.py b/test/adapter/sandbox_test.py index 6b8c967..131c380 100644 --- a/test/adapter/sandbox_test.py +++ b/test/adapter/sandbox_test.py @@ -57,20 +57,44 @@ def test_regular_command_null_token(self): other methods to control access (e.g., listen only on loopback interface, use IP address whitelist, etc.). """ - # :todo: Implement test. - self.skipTest('Not implemented yet.') + # 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(return_value=mocked_response) + + payload = {'command': 'helloWorld'} + + # noinspection PyUnresolvedReferences + with 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), + + # No auth token, so no Authorization header. + # headers = { + # 'Authorization': 'token ACCESS-TOKEN', + # }, + ) - def test_error_token_wrong_type(self): + def test_error_auth_token_wrong_type(self): """ - ``token`` is not a string. + ``auth_token`` is not a string. """ with self.assertRaises(TypeError): # Nope; it has to be a unicode string. SandboxAdapter('https://localhost', b'not valid') - def test_error_token_empty(self): + def test_error_auth_token_empty(self): """ - ``token`` is an empty string. + ``auth_token`` is an empty string. """ with self.assertRaises(ValueError): # If the node does not require authorization, use ``None``. From de11e7fbf2455591a98d1f9b5d3dc85b51069749 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 12:55:13 -0500 Subject: [PATCH 12/19] [#19] Basic support for sandbox nodes. --- src/iota/adapter/__init__.py | 48 ++++++++++++++------- src/iota/adapter/sandbox.py | 82 ++++++++++++++++++++++++++++++++--- test/adapter/sandbox_test.py | 83 +++++++++++++++++++++++++++++++++++- test/adapter_test.py | 21 +++++---- 4 files changed, 202 insertions(+), 32 deletions(-) diff --git a/src/iota/adapter/__init__.py b/src/iota/adapter/__init__.py index c867a71..3041cd0 100644 --- a/src/iota/adapter/__init__.py +++ b/src/iota/adapter/__init__.py @@ -7,7 +7,7 @@ from collections import deque from inspect import isabstract as is_abstract from socket import getdefaulttimeout as get_default_timeout -from typing import Dict, List, Text, Tuple, Union +from typing import Dict, List, Optional, Text, Tuple, Union import requests from iota.exceptions import with_context @@ -110,7 +110,8 @@ def __init__(cls, what, bases=None, dict=None): if not is_abstract(cls): for protocol in getattr(cls, 'supported_protocols', ()): - adapter_registry[protocol] = cls + # Note that we will not overwrite existing registered adapters. + adapter_registry.setdefault(protocol, cls) def configure(cls, parsed): # type: (Union[Text, SplitResult]) -> HttpAdapter @@ -227,9 +228,36 @@ def send_request(self, payload, **kwargs): response = self._send_http_request( # Use a custom JSON encoder that knows how to convert Tryte values. payload = JsonEncoder().encode(payload), + + url = self.node_url, **kwargs ) + return self._interpret_response(response, payload) + + @staticmethod + def _send_http_request(url, payload, method='post', **kwargs): + # type: (Text, Optional[Text], Text, dict) -> requests.Response + """ + Sends the actual HTTP request. + + Split into its own method so that it can be mocked during unit + tests. + """ + kwargs.setdefault('timeout', get_default_timeout()) + return requests.request(method=method, url=url, data=payload, **kwargs) + + def _interpret_response(self, response, payload): + # type: (requests.Response, dict) -> dict + """ + Interprets the HTTP response from the node. + + :param response: + The response object received from :py:meth:`_send_http_request`. + + :param payload: + The request payload that was sent (used for debugging). + """ raw_content = response.text if not raw_content: raise with_context( @@ -257,8 +285,9 @@ def send_request(self, payload, **kwargs): ) try: - # Response always has 200 status, even for errors/exceptions, so the - # only way to check for success is to inspect the response body. + # Response always has 200 status, even for errors/exceptions, so + # the only reliable way to check for success is to inspect the + # response body. # https://github.com/iotaledger/iri/issues/9 # https://github.com/iotaledger/iri/issues/12 error = decoded.get('exception') or decoded.get('error') @@ -280,17 +309,6 @@ def send_request(self, payload, **kwargs): return decoded - def _send_http_request(self, payload, **kwargs): - # type: (Text, dict) -> requests.Response - """ - Sends the actual HTTP request. - - Split into its own method so that it can be mocked during unit - tests. - """ - kwargs.setdefault('timeout', get_default_timeout()) - return requests.post(self.node_url, data=payload, **kwargs) - class MockAdapter(BaseAdapter): """ diff --git a/src/iota/adapter/sandbox.py b/src/iota/adapter/sandbox.py index 9eb3f4d..e30b667 100644 --- a/src/iota/adapter/sandbox.py +++ b/src/iota/adapter/sandbox.py @@ -2,14 +2,23 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals +from time import sleep from typing import Optional, Text, Union -from six import text_type +from requests import Response +from requests.status_codes import codes +from six import moves as compat, text_type from iota.adapter import HttpAdapter, SplitResult from iota.exceptions import with_context +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 @@ -119,14 +128,77 @@ def __init__(self, uri, auth_token, poll_interval=DEFAULT_POLL_INTERVAL): self.auth_token = auth_token # type: Optional[Text] self.poll_interval = poll_interval # 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 - kwargs['headers']['Authorization'] =\ - 'token {token}'.format( - token = self.auth_token, + return super(SandboxAdapter, self).send_request(payload, **kwargs) + + def _interpret_response(self, response, payload, check_for_poll=True): + # type: (Response, dict, bool) -> dict + decoded =\ + super(SandboxAdapter, self)._interpret_response(response, payload) + + # Check to see if the request was queued for asynchronous + # execution. + if check_for_poll and (response.status_code == codes['accepted']): + while decoded['status'] in (STATUS_QUEUED, STATUS_RUNNING): + self._wait_to_poll() + + poll_response = self._send_http_request( + headers = {'Authorization': self.authorization_header}, + method = 'get', + payload = None, + url = self.get_jobs_url(decoded['id']), ) - return super(SandboxAdapter, self).send_request(payload, **kwargs) + decoded = self._interpret_response(poll_response, payload, False) + + return decoded['{command}Response'.format(command=decoded['command'])] + + 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 index 131c380..8722e09 100644 --- a/test/adapter/sandbox_test.py +++ b/test/adapter/sandbox_test.py @@ -3,6 +3,7 @@ unicode_literals import json +from collections import deque from unittest import TestCase from mock import Mock, patch @@ -35,6 +36,7 @@ def test_regular_command(self): mocked_sender.assert_called_once_with( payload = json.dumps(payload), + url = adapter.node_url, # Auth token automatically added to the HTTP request. headers = { @@ -42,10 +44,88 @@ def test_regular_command(self): }, ) - def test_sandbox_command(self): + 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(wraps=_send_http_request) + mocked_waiter = Mock() + + payload = {'command': 'helloWorld'} + + # noinspection PyUnresolvedReferences + with patch.object(adapter, '_send_http_request', mocked_sender): + # Mock ``_wait_to_poll`` so that it returns immediately, instead + # of waiting for 15 seconds. Bad for production, good for tests. + # noinspection PyUnresolvedReferences + with patch.object(adapter, '_wait_to_poll', mocked_waiter): + result = adapter.send_request(payload) + + self.assertEqual(result, expected_result) + + def test_sandbox_command_fails(self): + """ + A sandbox command fails after an interval. + """ # :todo: Implement test. self.skipTest('Not implemented yet.') @@ -77,6 +157,7 @@ def test_regular_command_null_token(self): mocked_sender.assert_called_once_with( payload = json.dumps(payload), + url = adapter.node_url, # No auth token, so no Authorization header. # headers = { diff --git a/test/adapter_test.py b/test/adapter_test.py index f54a2ee..fc3ca80 100644 --- a/test/adapter_test.py +++ b/test/adapter_test.py @@ -53,22 +53,19 @@ def test_unknown_protocol(self): resolve_adapter('foobar://localhost:14265') -def create_http_response(content): - # type: (Text) -> requests.Response +def create_http_response(content, status=200): + # type: (Text, int) -> requests.Response """ Creates an HTTP Response object for a test. + + References: + - :py:meth:`requests.adapters.HTTPAdapter.build_response` """ - # :py:meth:`requests.adapters.HTTPAdapter.build_response` response = requests.Response() - # Response status is always 200, even for an error. - # Note that this is fixed in later IRI implementations, but for - # backwards-compatibility, the adapter will ignore the status code. - # https://github.com/iotaledger/iri/issues/9 - response.status_code = 200 - - response.encoding = 'utf-8' - response.raw = BytesIO(content.encode('utf-8')) + response.encoding = 'utf-8' + response.status_code = status + response.raw = BytesIO(content.encode('utf-8')) return response @@ -287,6 +284,8 @@ def test_trytes_in_request(): }) mocked_sender.assert_called_once_with( + url = adapter.node_url, + payload = json.dumps({ 'command': 'helloWorld', From 40f092cd8c217acd84c41a138cbbddbd8153cff7 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 13:02:31 -0500 Subject: [PATCH 13/19] [#19] Error reporting for SandboxAdapter. --- src/iota/adapter/sandbox.py | 4 ++ test/adapter/sandbox_test.py | 75 +++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/iota/adapter/sandbox.py b/src/iota/adapter/sandbox.py index e30b667..233f039 100644 --- a/src/iota/adapter/sandbox.py +++ b/src/iota/adapter/sandbox.py @@ -12,6 +12,10 @@ from iota.adapter import HttpAdapter, SplitResult from iota.exceptions import with_context +__all__ = [ + 'SandboxAdapter', +] + STATUS_ABORTED = 'ABORTED' STATUS_FAILED = 'FAILED' diff --git a/test/adapter/sandbox_test.py b/test/adapter/sandbox_test.py index 8722e09..c1faecf 100644 --- a/test/adapter/sandbox_test.py +++ b/test/adapter/sandbox_test.py @@ -8,6 +8,7 @@ from mock import Mock, patch +from iota import BadApiResponse from iota.adapter.sandbox import SandboxAdapter from test.adapter_test import create_http_response @@ -110,15 +111,13 @@ def _send_http_request(*args, **kwargs): mocked_sender = Mock(wraps=_send_http_request) mocked_waiter = Mock() - payload = {'command': 'helloWorld'} - # noinspection PyUnresolvedReferences with patch.object(adapter, '_send_http_request', mocked_sender): # Mock ``_wait_to_poll`` so that it returns immediately, instead # of waiting for 15 seconds. Bad for production, good for tests. # noinspection PyUnresolvedReferences with patch.object(adapter, '_wait_to_poll', mocked_waiter): - result = adapter.send_request(payload) + result = adapter.send_request({'command': 'helloWorld'}) self.assertEqual(result, expected_result) @@ -126,8 +125,74 @@ def test_sandbox_command_fails(self): """ A sandbox command fails after an interval. """ - # :todo: Implement test. - self.skipTest('Not implemented yet.') + adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') + + # 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': "You didn't say the magic word!" + }, + })), + ]) + + # noinspection PyUnusedLocal + def _send_http_request(*args, **kwargs): + return responses.popleft() + + mocked_sender = Mock(wraps=_send_http_request) + mocked_waiter = Mock() + + # noinspection PyUnresolvedReferences + with patch.object(adapter, '_send_http_request', mocked_sender): + # Mock ``_wait_to_poll`` so that it returns immediately, instead + # of waiting for 15 seconds. Bad for production, good for tests. + # noinspection PyUnresolvedReferences + with patch.object(adapter, '_wait_to_poll', mocked_waiter): + with self.assertRaises(BadApiResponse): + adapter.send_request({'command': 'helloWorld'}) def test_regular_command_null_token(self): """ From 4c2baf3005492b2c80e558978b3382d284797740 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 13:57:44 -0500 Subject: [PATCH 14/19] Overhauled error handling for HTTP requests. --- src/iota/adapter/__init__.py | 64 ++++++++++++++++++---------- src/iota/adapter/sandbox.py | 44 +++++++++++++++----- test/adapter/sandbox_test.py | 34 ++++++++++++++- test/adapter_test.py | 81 +++++++++++++++++++++++++++--------- 4 files changed, 170 insertions(+), 53 deletions(-) diff --git a/src/iota/adapter/__init__.py b/src/iota/adapter/__init__.py index 3041cd0..3d95b04 100644 --- a/src/iota/adapter/__init__.py +++ b/src/iota/adapter/__init__.py @@ -7,9 +7,9 @@ from collections import deque from inspect import isabstract as is_abstract from socket import getdefaulttimeout as get_default_timeout -from typing import Dict, List, Optional, Text, Tuple, Union +from typing import Container, Dict, List, Optional, Text, Tuple, Union -import requests +from requests import Response, codes, request from iota.exceptions import with_context from iota.json import JsonEncoder from six import PY2, binary_type, moves as compat, text_type, with_metaclass @@ -233,11 +233,11 @@ def send_request(self, payload, **kwargs): **kwargs ) - return self._interpret_response(response, payload) + return self._interpret_response(response, payload, {codes['ok']}) @staticmethod def _send_http_request(url, payload, method='post', **kwargs): - # type: (Text, Optional[Text], Text, dict) -> requests.Response + # type: (Text, Optional[Text], Text, dict) -> Response """ Sends the actual HTTP request. @@ -245,10 +245,10 @@ def _send_http_request(url, payload, method='post', **kwargs): tests. """ kwargs.setdefault('timeout', get_default_timeout()) - return requests.request(method=method, url=url, data=payload, **kwargs) + return request(method=method, url=url, data=payload, **kwargs) - def _interpret_response(self, response, payload): - # type: (requests.Response, dict) -> dict + def _interpret_response(self, response, payload, expected_status): + # type: (Response, dict, Container[int]) -> dict """ Interprets the HTTP response from the node. @@ -257,6 +257,10 @@ def _interpret_response(self, response, payload): :param payload: The request payload that was sent (used for debugging). + + :param expected_status: + The response should match one of these status codes to be + considered valid. """ raw_content = response.text if not raw_content: @@ -280,34 +284,50 @@ def _interpret_response(self, response, payload): ), context = { - 'request': payload, + 'request': payload, + 'raw_response': raw_content, }, ) - try: - # Response always has 200 status, even for errors/exceptions, so - # the only reliable way to check for success is to inspect the - # response body. - # https://github.com/iotaledger/iri/issues/9 - # https://github.com/iotaledger/iri/issues/12 - error = decoded.get('exception') or decoded.get('error') - except AttributeError: + if not isinstance(decoded, dict): raise with_context( exc = BadApiResponse( - 'Invalid response from node: {raw_content}'.format( - raw_content = raw_content, + 'Invalid response from node: {decoded!r}'.format( + decoded = decoded, ), ), context = { - 'request': payload, + 'request': payload, + 'response': decoded, }, ) - if error: - raise with_context(BadApiResponse(error), context={'request': payload}) + if response.status_code in expected_status: + return decoded - return decoded + error = None + try: + if response.status_code == codes['bad_request']: + error = decoded['error'] + elif response.status_code == codes['internal_server_error']: + error = decoded['exception'] + except KeyError: + pass + + raise with_context( + exc = BadApiResponse( + '{status} response from node: {error}'.format( + error = error or decoded, + status = response.status_code, + ), + ), + + context = { + 'request': payload, + 'response': decoded, + }, + ) class MockAdapter(BaseAdapter): diff --git a/src/iota/adapter/sandbox.py b/src/iota/adapter/sandbox.py index 233f039..63bbf48 100644 --- a/src/iota/adapter/sandbox.py +++ b/src/iota/adapter/sandbox.py @@ -3,13 +3,12 @@ unicode_literals from time import sleep -from typing import Optional, Text, Union +from typing import Container, Optional, Text, Union -from requests import Response -from requests.status_codes import codes +from requests import Response, codes from six import moves as compat, text_type -from iota.adapter import HttpAdapter, SplitResult +from iota.adapter import BadApiResponse, HttpAdapter, SplitResult from iota.exceptions import with_context __all__ = [ @@ -174,14 +173,18 @@ def send_request(self, payload, **kwargs): return super(SandboxAdapter, self).send_request(payload, **kwargs) - def _interpret_response(self, response, payload, check_for_poll=True): - # type: (Response, dict, bool) -> dict + def _interpret_response(self, response, payload, expected_status): + # type: (Response, dict, Container[int], bool) -> dict decoded =\ - super(SandboxAdapter, self)._interpret_response(response, payload) + 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 check_for_poll and (response.status_code == codes['accepted']): + if response.status_code == codes['accepted']: while decoded['status'] in (STATUS_QUEUED, STATUS_RUNNING): self._wait_to_poll() @@ -192,9 +195,30 @@ def _interpret_response(self, response, payload, check_for_poll=True): url = self.get_jobs_url(decoded['id']), ) - decoded = self._interpret_response(poll_response, payload, False) + 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(), + ), + ), - return decoded['{command}Response'.format(command=decoded['command'])] + context = { + 'request': payload, + 'response': decoded, + }, + ) return decoded diff --git a/test/adapter/sandbox_test.py b/test/adapter/sandbox_test.py index c1faecf..aedb679 100644 --- a/test/adapter/sandbox_test.py +++ b/test/adapter/sandbox_test.py @@ -7,6 +7,7 @@ from unittest import TestCase from mock import Mock, patch +from six import text_type from iota import BadApiResponse from iota.adapter.sandbox import SandboxAdapter @@ -127,6 +128,8 @@ def test_sandbox_command_fails(self): """ adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') + error_message = "You didn't say the magic word!" + # Simulate responses from the node. responses =\ deque([ @@ -173,7 +176,7 @@ def test_sandbox_command_fails(self): }, 'error': { - 'message': "You didn't say the magic word!" + 'message': error_message, }, })), ]) @@ -191,9 +194,11 @@ def _send_http_request(*args, **kwargs): # of waiting for 15 seconds. Bad for production, good for tests. # noinspection PyUnresolvedReferences with patch.object(adapter, '_wait_to_poll', mocked_waiter): - with self.assertRaises(BadApiResponse): + 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. @@ -230,6 +235,31 @@ def test_regular_command_null_token(self): # }, ) + 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(return_value=create_http_response( + status = 429, + content = json.dumps(decoded_response), + )) + + # noinspection PyUnresolvedReferences + with 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. diff --git a/test/adapter_test.py b/test/adapter_test.py index fc3ca80..0989dc2 100644 --- a/test/adapter_test.py +++ b/test/adapter_test.py @@ -10,7 +10,7 @@ from iota import BadApiResponse, InvalidUri, TryteString from iota.adapter import HttpAdapter, MockAdapter, resolve_adapter from mock import Mock, patch -from six import BytesIO, text_type as text +from six import BytesIO, text_type class ResolveAdapterTestCase(TestCase): @@ -160,12 +160,16 @@ def test_error_response(self): """ adapter = HttpAdapter('http://localhost:14265') - expected_result = 'Command \u0027helloWorld\u0027 is unknown' + error_message = 'Command \u0027helloWorld\u0027 is unknown' - mocked_response = create_http_response(json.dumps({ - 'error': expected_result, - 'duration': 42, - })) + mocked_response = create_http_response( + status = 400, + + content = json.dumps({ + 'error': error_message, + 'duration': 42, + }), + ) mocked_sender = Mock(return_value=mocked_response) @@ -174,7 +178,10 @@ def test_error_response(self): with self.assertRaises(BadApiResponse) as context: adapter.send_request({'command': 'helloWorld'}) - self.assertEqual(text(context.exception), expected_result) + self.assertEqual( + text_type(context.exception), + '400 response from node: {error}'.format(error=error_message), + ) def test_exception_response(self): """ @@ -183,12 +190,42 @@ def test_exception_response(self): """ adapter = HttpAdapter('http://localhost:14265') - expected_result = 'java.lang.ArrayIndexOutOfBoundsException: 4' + error_message = 'java.lang.ArrayIndexOutOfBoundsException: 4' + + mocked_response = create_http_response( + status = 500, + + content = json.dumps({ + 'exception': error_message, + 'duration': 16, + }), + ) + + mocked_sender = Mock(return_value=mocked_response) + + # noinspection PyUnresolvedReferences + with 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), + '500 response from node: {error}'.format(error=error_message), + ) + + def test_non_200_status(self): + """ + The node sends back a non-200 response that we don't know how to + handle. + """ + adapter = HttpAdapter('http://localhost') + + decoded_response = {'message': 'Request limit exceeded.'} - mocked_response = create_http_response(json.dumps({ - 'exception': 'java.lang.ArrayIndexOutOfBoundsException: 4', - 'duration': 16 - })) + mocked_response = create_http_response( + status = 429, + content = json.dumps(decoded_response), + ) mocked_sender = Mock(return_value=mocked_response) @@ -197,7 +234,10 @@ def test_exception_response(self): with self.assertRaises(BadApiResponse) as context: adapter.send_request({'command': 'helloWorld'}) - self.assertEqual(text(context.exception), expected_result) + self.assertEqual( + text_type(context.exception), + '429 response from node: {decoded}'.format(decoded=decoded_response), + ) def test_empty_response(self): """ @@ -214,7 +254,7 @@ def test_empty_response(self): with self.assertRaises(BadApiResponse) as context: adapter.send_request({'command': 'helloWorld'}) - self.assertEqual(text(context.exception), 'Empty response from node.') + self.assertEqual(text_type(context.exception), 'Empty response from node.') def test_non_json_response(self): """ @@ -233,7 +273,7 @@ def test_non_json_response(self): adapter.send_request({'command': 'helloWorld'}) self.assertEqual( - text(context.exception), + text_type(context.exception), 'Non-JSON response from node: ' + invalid_response, ) @@ -243,8 +283,8 @@ def test_non_object_response(self): """ adapter = HttpAdapter('http://localhost:14265') - invalid_response = '["message", "Hello, IOTA!"]' - mocked_response = create_http_response(invalid_response) + invalid_response = ['message', 'Hello, IOTA!'] + mocked_response = create_http_response(json.dumps(invalid_response)) mocked_sender = Mock(return_value=mocked_response) @@ -254,8 +294,11 @@ def test_non_object_response(self): adapter.send_request({'command': 'helloWorld'}) self.assertEqual( - text(context.exception), - 'Invalid response from node: ' + invalid_response, + text_type(context.exception), + + 'Invalid response from node: {response!r}'.format( + response = invalid_response, + ), ) # noinspection SpellCheckingInspection From 82bde82b9d82385a4ccabf992c9f8e633d4a1823 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 13:58:13 -0500 Subject: [PATCH 15/19] Bumping version number. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 45c5528..87dbd0c 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ name = 'PyOTA', description = 'IOTA API library for Python', url = 'https://github.com/iotaledger/iota.lib.py', - version = '1.0.0', + version = '1.1.0b1', packages = find_packages('src'), include_package_data = True, From 380fad8761e14ca01e44f06bd47ec36ced78b37c Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 14:12:54 -0500 Subject: [PATCH 16/19] [#19] Added example script for sandbox mode. --- examples/sandbox.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 examples/sandbox.py diff --git a/examples/sandbox.py b/examples/sandbox.py new file mode 100644 index 0000000..6a570f1 --- /dev/null +++ b/examples/sandbox.py @@ -0,0 +1,55 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from iota import * +from iota.adapter.sandbox import SandboxAdapter + + +# Create the API object. +iota =\ + 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', + ) + +# Example of sending a transfer using the sandbox. +# For more information, see :py:meth:`Iota.send_transfer`. +# noinspection SpellCheckingInspection +iota.send_transfer( + depth = 100, + + # 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!'), + ), + ], +) From 41a691ecd5050db830f1f7646dc2503b0661f476 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 15:51:11 -0500 Subject: [PATCH 17/19] Bumping version number. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 87dbd0c..7c1cd74 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ name = 'PyOTA', description = 'IOTA API library for Python', url = 'https://github.com/iotaledger/iota.lib.py', - version = '1.1.0b1', + version = '1.1.0rc1', packages = find_packages('src'), include_package_data = True, From 2e821519e11d0456f19516b19a3376cc3e5d10d5 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 16:40:03 -0500 Subject: [PATCH 18/19] Integrated logger directly into HttpAdapter. --- examples/shell.py | 10 +++-- src/iota/adapter/__init__.py | 73 ++++++++++++++++++++++++++++++++++-- src/iota/adapter/wrappers.py | 40 +------------------- test/adapter/sandbox_test.py | 12 +++--- test/adapter_test.py | 12 ++++-- 5 files changed, 92 insertions(+), 55 deletions(-) diff --git a/examples/shell.py b/examples/shell.py index 487354f..6283536 100644 --- a/examples/shell.py +++ b/examples/shell.py @@ -7,7 +7,7 @@ from argparse import ArgumentParser from getpass import getpass as secure_input -from logging import INFO, basicConfig, getLogger +from logging import DEBUG, basicConfig, getLogger from sys import argv, stderr from six import text_type @@ -18,10 +18,10 @@ from iota import __version__ from iota.adapter import resolve_adapter -from iota.adapter.wrappers import LogWrapper, RoutingWrapper +from iota.adapter.wrappers import RoutingWrapper -basicConfig(level=INFO, stream=stderr) +basicConfig(level=DEBUG, stream=stderr) def main(uri, testnet, pow_uri, debug_requests): @@ -47,7 +47,9 @@ def main(uri, testnet, pow_uri, debug_requests): # If ``debug_requests`` is specified, log HTTP requests/responses. if debug_requests: - adapter_ = LogWrapper(adapter_, getLogger(__name__), INFO) + logger = getLogger(__name__) + logger.setLevel(DEBUG) + adapter_.set_logger(logger) iota = Iota(adapter_, seed=seed, testnet=testnet) diff --git a/src/iota/adapter/__init__.py b/src/iota/adapter/__init__.py index 3d95b04..f679e9f 100644 --- a/src/iota/adapter/__init__.py +++ b/src/iota/adapter/__init__.py @@ -6,6 +6,7 @@ from abc import ABCMeta, abstractmethod as abstract_method from collections import deque from inspect import isabstract as is_abstract +from logging import DEBUG, Logger from socket import getdefaulttimeout as get_default_timeout from typing import Container, Dict, List, Optional, Text, Tuple, Union @@ -136,6 +137,12 @@ class BaseAdapter(with_metaclass(AdapterMeta)): Protocols that ``resolve_adapter`` can use to identify this adapter type. """ + + def __init__(self): + super(BaseAdapter, self).__init__() + + self._logger = None # type: Logger + @abstract_method def send_request(self, payload, **kwargs): # type: (dict, dict) -> dict @@ -159,6 +166,24 @@ def send_request(self, payload, **kwargs): 'Not implemented in {cls}.'.format(cls=type(self).__name__), ) + def set_logger(self, logger): + # type: (Logger) -> BaseAdapter + """ + Attaches a logger instance to the adapter. + The adapter will send information about API requests/responses to + the logger. + """ + self._logger = logger + return self + + def _log(self, level, message, context=None): + # type: (int, Text, Optional[dict]) -> None + """ + Sends a message to the instance's logger, if configured. + """ + if self._logger: + self._logger.log(level, message, extra={'context': context or {}}) + class HttpAdapter(BaseAdapter): """ @@ -225,6 +250,9 @@ def node_url(self): def send_request(self, payload, **kwargs): # type: (dict, dict) -> dict + kwargs.setdefault('headers', {}) + kwargs['headers']['Content-type'] = 'application/json' + response = self._send_http_request( # Use a custom JSON encoder that knows how to convert Tryte values. payload = JsonEncoder().encode(payload), @@ -235,8 +263,7 @@ def send_request(self, payload, **kwargs): return self._interpret_response(response, payload, {codes['ok']}) - @staticmethod - def _send_http_request(url, payload, method='post', **kwargs): + def _send_http_request(self, url, payload, method='post', **kwargs): # type: (Text, Optional[Text], Text, dict) -> Response """ Sends the actual HTTP request. @@ -245,7 +272,47 @@ def _send_http_request(url, payload, method='post', **kwargs): tests. """ kwargs.setdefault('timeout', get_default_timeout()) - return request(method=method, url=url, data=payload, **kwargs) + + self._log( + level = DEBUG, + + message = 'Sending {method} to {url}: {payload!r}'.format( + method = method, + payload = payload, + url = url, + ), + + context = { + 'request_method': method, + 'request_kwargs': kwargs, + 'request_payload': payload, + 'request_url': url, + }, + ) + + response = request(method=method, url=url, data=payload, **kwargs) + + self._log( + level = DEBUG, + + message = 'Receiving {method} from {url}: {response!r}'.format( + method = method, + response = response.content, + url = url, + ), + + context = { + 'request_method': method, + 'request_kwargs': kwargs, + 'request_payload': payload, + 'request_url': url, + + 'response_headers': response.headers, + 'response_content': response.content, + }, + ) + + return response def _interpret_response(self, response, payload, expected_status): # type: (Response, dict, Container[int]) -> dict diff --git a/src/iota/adapter/wrappers.py b/src/iota/adapter/wrappers.py index e39f199..1ab738b 100644 --- a/src/iota/adapter/wrappers.py +++ b/src/iota/adapter/wrappers.py @@ -3,15 +3,12 @@ unicode_literals from abc import ABCMeta, abstractmethod as abstract_method -from logging import INFO, Logger from typing import Dict, Text -from six import with_metaclass - from iota.adapter import AdapterSpec, BaseAdapter, resolve_adapter +from six import with_metaclass __all__ = [ - 'LogWrapper', 'RoutingWrapper', ] @@ -38,41 +35,6 @@ def send_request(self, payload, **kwargs): ) -class LogWrapper(BaseWrapper): - """ - Wrapper that sends all adapter requests and responses to a logger. - - To use it, "wrap" the real adapter instance/spec:: - - logger = getLogger('...') - api = Iota(LogWrapper('http://localhost:14265', logger)) - """ - def __init__(self, adapter, logger, level=INFO): - # type: (AdapterSpec, Logger, int) -> None - super(LogWrapper, self).__init__(adapter) - - self.logger = logger - self.level = level - - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - command = payload.get('command') or 'command' - - self.logger.log(self.level, 'Sending {command}: {request!r}'.format( - command = command, - request = payload, - )) - - response = self.adapter.send_request(payload, **kwargs) - - self.logger.log(self.level, 'Receiving {command}: {response!r}'.format( - command = command, - response = response, - )) - - return response - - class RoutingWrapper(BaseWrapper): """ Routes commands to different nodes. diff --git a/test/adapter/sandbox_test.py b/test/adapter/sandbox_test.py index aedb679..1b22dfa 100644 --- a/test/adapter/sandbox_test.py +++ b/test/adapter/sandbox_test.py @@ -42,7 +42,8 @@ def test_regular_command(self): # Auth token automatically added to the HTTP request. headers = { - 'Authorization': 'token ACCESS-TOKEN', + 'Authorization': 'token ACCESS-TOKEN', + 'Content-type': 'application/json', }, ) @@ -229,10 +230,11 @@ def test_regular_command_null_token(self): payload = json.dumps(payload), url = adapter.node_url, - # No auth token, so no Authorization header. - # headers = { - # 'Authorization': 'token ACCESS-TOKEN', - # }, + headers = { + # No auth token, so no Authorization header. + # 'Authorization': 'token ACCESS-TOKEN', + 'Content-type': 'application/json', + }, ) def test_error_non_200_response(self): diff --git a/test/adapter_test.py b/test/adapter_test.py index 0989dc2..5a69a3f 100644 --- a/test/adapter_test.py +++ b/test/adapter_test.py @@ -26,14 +26,14 @@ def test_adapter_instance(self): def test_http(self): """ - Resolving a valid `http://` URI. + Resolving a valid ``http://`` URI. """ adapter = resolve_adapter('http://localhost:14265/') self.assertIsInstance(adapter, HttpAdapter) def test_https(self): """ - Resolving a valid `https://` URI. + Resolving a valid ``https://`` URI. """ adapter = resolve_adapter('https://localhost:14265/') self.assertIsInstance(adapter, HttpAdapter) @@ -73,7 +73,7 @@ def create_http_response(content, status=200): class HttpAdapterTestCase(TestCase): def test_http(self): """ - Configuring HttpAdapter using a valid http:// URI. + Configuring HttpAdapter using a valid ``http://`` URI. """ uri = 'http://localhost:14265/' adapter = HttpAdapter(uri) @@ -82,7 +82,7 @@ def test_http(self): def test_https(self): """ - Configuring HttpAdapter using a valid https:// URI. + Configuring HttpAdapter using a valid ``https://`` URI. """ uri = 'https://localhost:14265/' adapter = HttpAdapter(uri) @@ -338,4 +338,8 @@ def test_trytes_in_request(): 'CCPCBDVC9DTCEAKDXC9D9DEARCWCPCBDVCTCEAHDWCTCEAKDCDFD9DSCSA', ], }), + + headers = { + 'Content-type': 'application/json', + }, ) From cd51ca4a0d87e500674a2b817161942b0d6ff7a6 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Sat, 28 Jan 2017 16:40:27 -0500 Subject: [PATCH 19/19] Bumping version number. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7c1cd74..6c79011 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ name = 'PyOTA', description = 'IOTA API library for Python', url = 'https://github.com/iotaledger/iota.lib.py', - version = '1.1.0rc1', + version = '1.1.0', packages = find_packages('src'), include_package_data = True,