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!'), + ), + ], +) diff --git a/examples/shell.py b/examples/shell.py index acfb10b..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): @@ -34,16 +34,23 @@ 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: + logger = getLogger(__name__) + logger.setLevel(DEBUG) + adapter_.set_logger(logger) + iota = Iota(adapter_, seed=seed, testnet=testnet) _banner = ( @@ -57,26 +64,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 +89,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/setup.py b/setup.py index 497d6e3..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.0.0', + version = '1.1.0', packages = find_packages('src'), include_package_data = True, @@ -37,7 +37,7 @@ install_requires = [ 'filters', - 'requests', + 'requests[security]', 'six', 'typing', ], diff --git a/src/iota/adapter/__init__.py b/src/iota/adapter/__init__.py index f3444f8..f679e9f 100644 --- a/src/iota/adapter/__init__.py +++ b/src/iota/adapter/__init__.py @@ -6,14 +6,14 @@ 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 Dict, List, Text, Tuple, Union +from typing import Container, Dict, List, Optional, Text, Tuple, Union -import requests -from iota import DEFAULT_PORT +from requests import Response, codes, request 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,76 @@ 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 + # Note that we will not overwrite existing registered adapters. + adapter_registry.setdefault(protocol, cls) - def configure(cls, uri): - # type: (Text) -> BaseAdapter + def configure(cls, parsed): + # type: (Union[Text, SplitResult]) -> HttpAdapter """ - Creates a new adapter from the specified URI. + Creates a new instance using the specified URI. + + :param parsed: + Result of :py:func:`urllib.parse.urlsplit`. """ - return cls(uri) + return cls(parsed) -class BaseAdapter(with_metaclass(_AdapterMeta)): +class BaseAdapter(with_metaclass(AdapterMeta)): """ Interface for IOTA API adapters. @@ -120,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 @@ -143,75 +166,79 @@ 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): """ Sends standard HTTP requests. """ - supported_protocols = ('udp', 'http',) + supported_protocols = ('http', 'https',) - @classmethod - def configure(cls, uri): - # type: (Text) -> HttpAdapter - """ - Creates a new instance using the specified URI. + def __init__(self, uri): + # type: (Union[Text, SplitResult]) -> None + super(HttpAdapter, self).__init__() - :param uri: - E.g., `udp://localhost:14265/` - """ - 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(uri, text_type): + uri = compat.urllib_parse.urlsplit(uri) # 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 uri.scheme not in self.supported_protocols: + raise with_context( + exc = InvalidUri('Unsupported protocol {protocol!r}.'.format( + protocol = uri.scheme, + )), - try: - host, port = server.split(':', 1) - except ValueError: - host = server + context = { + 'uri': uri, + }, + ) - if protocol == 'http': - port = 80 - else: - port = DEFAULT_PORT + if not uri.hostname: + raise with_context( + exc = InvalidUri( + 'Empty hostname in URI {uri!r}.'.format( + uri = uri.geturl(), + ), + ), - if not host: - raise InvalidUri('Empty hostname in URI {uri!r}.'.format(uri=uri)) + context = { + 'uri': uri, + }, + ) try: - port = int(port) + # noinspection PyStatementEffect + uri.port except ValueError: - raise InvalidUri('Non-numeric port in URI {uri!r}.'.format(uri=uri)) - - return cls(host, port, path) - + raise with_context( + exc = InvalidUri( + 'Non-numeric port in URI {uri!r}.'.format( + uri = uri.geturl(), + ), + ), - def __init__(self, host, port=DEFAULT_PORT, path='/'): - # type: (Text, int) -> None - super(HttpAdapter, self).__init__() + context = { + 'uri': uri, + }, + ) - self.host = host - self.port = port - self.path = path + self.uri = uri @property def node_url(self): @@ -219,20 +246,89 @@ 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 + 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), + + url = self.node_url, **kwargs ) + return self._interpret_response(response, payload, {codes['ok']}) + + def _send_http_request(self, url, payload, method='post', **kwargs): + # type: (Text, Optional[Text], Text, dict) -> 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()) + + 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 + """ + 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). + + :param expected_status: + The response should match one of these status codes to be + considered valid. + """ raw_content = response.text if not raw_content: raise with_context( @@ -255,44 +351,50 @@ def send_request(self, payload, **kwargs): ), context = { - 'request': payload, + 'request': payload, + 'raw_response': raw_content, }, ) - try: - # Response always has 200 status, even for errors/exceptions, so the - # only 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 - def _send_http_request(self, payload, **kwargs): - # type: (Text, dict) -> requests.Response - """ - Sends the actual HTTP request. + raise with_context( + exc = BadApiResponse( + '{status} response from node: {error}'.format( + error = error or decoded, + status = response.status_code, + ), + ), - 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) + context = { + 'request': payload, + 'response': decoded, + }, + ) class MockAdapter(BaseAdapter): diff --git a/src/iota/adapter/sandbox.py b/src/iota/adapter/sandbox.py new file mode 100644 index 0000000..63bbf48 --- /dev/null +++ b/src/iota/adapter/sandbox.py @@ -0,0 +1,232 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from time import sleep +from typing import Container, Optional, Text, Union + +from requests import Response, codes +from six import moves as compat, text_type + +from iota.adapter import BadApiResponse, HttpAdapter, SplitResult +from iota.exceptions import with_context + +__all__ = [ + 'SandboxAdapter', +] + + +STATUS_ABORTED = 'ABORTED' +STATUS_FAILED = 'FAILED' +STATUS_FINISHED = 'FINISHED' +STATUS_QUEUED = 'QUEUED' +STATUS_RUNNING = 'RUNNING' + +class SandboxAdapter(HttpAdapter): + """ + HTTP adapter that sends requests to remote nodes operating in + "sandbox" mode. + + In sandbox mode, the node will only accept authenticated requests + from clients, and certain jobs are completed asynchronously. + + Note: for compatibility with Iota APIs, SandboxAdapter still operates + synchronously; it blocks until it determines that a job has completed + successfully. + + References: + - https://github.com/iotaledger/iota.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, auth_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 auth_token: + Authorization token used to authenticate requests. + + Contact the node's maintainer to obtain a token. + + If ``None``, the adapter will not include authorization metadata + with requests. + + :param poll_interval: + Number of seconds to wait between requests to check job status. + Must be a positive integer. + + Smaller values will cause the adapter to return a result sooner + (once the node completes the job), but it increases traffic to + the node (which may trip a rate limiter and/or incur additional + costs). + """ + super(SandboxAdapter, self).__init__(uri) + + if not (isinstance(auth_token, text_type) or (auth_token is None)): + raise with_context( + exc = + TypeError( + '``auth_token`` must be a unicode string or ``None`` ' + '(``exc.context`` has more info).' + ), + + context = { + 'auth_token': auth_token, + }, + ) + + if auth_token == '': + raise with_context( + exc = + ValueError( + 'Set ``auth_token=None`` if requests do not require authorization ' + '(``exc.context`` has more info).', + ), + + context = { + 'auth_token': auth_token, + }, + ) + + if not isinstance(poll_interval, int): + raise with_context( + exc = + TypeError( + '``poll_interval`` must be an int ' + '(``exc.context`` has more info).', + ), + + context = { + 'poll_interval': poll_interval, + }, + ) + + if poll_interval < 1: + raise with_context( + exc = + ValueError( + '``poll_interval`` must be >= 1 ' + '(``exc.context`` has more info).', + ), + + context = { + 'poll_interval': 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 + + return super(SandboxAdapter, self).send_request(payload, **kwargs) + + def _interpret_response(self, response, payload, expected_status): + # type: (Response, dict, Container[int], bool) -> dict + decoded =\ + super(SandboxAdapter, self)._interpret_response( + response = response, + payload = payload, + expected_status = {codes['ok'], codes['accepted']}, + ) + + # Check to see if the request was queued for asynchronous + # execution. + if response.status_code == codes['accepted']: + 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']), + ) + + decoded =\ + super(SandboxAdapter, self)._interpret_response( + response = poll_response, + payload = payload, + expected_status = {codes['ok']}, + ) + + if decoded['status'] == STATUS_FINISHED: + return decoded['{command}Response'.format(command=decoded['command'])] + + raise with_context( + exc = BadApiResponse( + decoded.get('error', {}).get('message') + or 'Command {status}: {decoded}'.format( + decoded = decoded, + status = decoded['status'].lower(), + ), + ), + + context = { + 'request': payload, + 'response': decoded, + }, + ) + + return decoded + + def _wait_to_poll(self): + """ + Waits for 1 interval (according to :py:attr:`poll_interval`). + + Implemented as a separate method so that it can be mocked during + unit tests ("Do you bite your thumb at us, sir?"). + """ + sleep(self.poll_interval) diff --git a/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/src/iota/api.py b/src/iota/api.py index d37882d..f92ed62 100644 --- a/src/iota/api.py +++ b/src/iota/api.py @@ -2,21 +2,53 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from typing import Dict, Iterable, List, Optional, Text +from typing import Dict, Iterable, 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.commands import BaseCommand, CustomCommand, discover_commands from iota.crypto.types import Seed +from six import with_metaclass __all__ = [ + 'InvalidCommand', 'Iota', 'StrictIota', ] -class StrictIota(object): +class InvalidCommand(ValueError): + """ + Indicates that an invalid command name was specified. + """ + pass + + +class ApiMeta(type): + """ + Manages command registries for IOTA API base classes. + """ + def __init__(cls, name, bases=None, attrs=None): + super(ApiMeta, cls).__init__(name, bases, attrs) + + if not hasattr(cls, 'commands'): + cls.commands = {} + + # Copy command registry from base class to derived class, but + # in the event of a conflict, preserve the derived class' + # commands. + commands = {} + for base in bases: + if isinstance(base, ApiMeta): + commands.update(base.commands) + + if commands: + commands.update(cls.commands) + cls.commands = commands + + +class StrictIota(with_metaclass(ApiMeta)): """ API to send HTTP requests for communicating with an IOTA node. @@ -26,6 +58,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 +78,45 @@ 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:`create_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) + command_class = self.commands[command] except KeyError: - return CustomCommand(self.adapter, command) + raise InvalidCommand( + '{cls} does not support {command!r} command.'.format( + cls = type(self).__name__, + command = command, + ), + ) + + return command_class(self.adapter) + + def create_command(self, command): + # type: (Text) -> CustomCommand + """ + 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 +390,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 """ @@ -348,10 +406,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 +431,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 +496,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,12 +540,17 @@ 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) 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. @@ -492,10 +567,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 @@ -513,7 +590,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 +606,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 +627,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 +649,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 +670,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 +689,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 +716,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 +746,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 +769,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 +784,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 diff --git a/src/iota/commands/__init__.py b/src/iota/commands/__init__.py index 8101597..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 @@ -22,33 +23,53 @@ ] 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): - # type: (Union[ModuleType, Text], bool) -> None + # type: (Union[ModuleType, Text], bool) -> Dict[Text, 'CommandMeta'] """ Automatically discover commands in the specified package. - :param package: Package path or reference. - :param recursively: If True, will descend recursively into - sub-packages. + :param package: + Package path or reference. + + :param recursively: + If True, will descend recursively into sub-packages. + + :return: + All commands discovered in the specified package, indexed by + command name (note: not class name). """ # :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. + # 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): - """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 +81,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 +100,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 +156,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 +175,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 +188,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 +206,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 +229,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 +253,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 +263,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 +277,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 +303,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) 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/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/sandbox_test.py b/test/adapter/sandbox_test.py new file mode 100644 index 0000000..1b22dfa --- /dev/null +++ b/test/adapter/sandbox_test.py @@ -0,0 +1,306 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import json +from collections import deque +from unittest import TestCase + +from mock import Mock, patch +from six import text_type + +from iota import BadApiResponse +from iota.adapter.sandbox import SandboxAdapter +from test.adapter_test import create_http_response + + +class SandboxAdapterTestCase(TestCase): + def test_regular_command(self): + """ + Sending a non-sandbox command to the node. + """ + adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') + + expected_result = { + 'message': 'Hello, IOTA!', + } + + mocked_response = create_http_response(json.dumps(expected_result)) + mocked_sender = Mock(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), + url = adapter.node_url, + + # Auth token automatically added to the HTTP request. + headers = { + 'Authorization': 'token ACCESS-TOKEN', + 'Content-type': 'application/json', + }, + ) + + 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() + + # 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({'command': 'helloWorld'}) + + self.assertEqual(result, expected_result) + + def test_sandbox_command_fails(self): + """ + A sandbox command fails after an interval. + """ + adapter = SandboxAdapter('https://localhost', 'ACCESS-TOKEN') + + error_message = "You didn't say the magic word!" + + # Simulate responses from the node. + responses =\ + deque([ + # The first request creates the job. + # Note that the response has a 202 status. + create_http_response(status=202, content=json.dumps({ + 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', + 'status': 'QUEUED', + 'createdAt': 1483574581, + 'startedAt': None, + 'finishedAt': None, + 'command': 'helloWorld', + + 'helloWorldRequest': { + 'command': 'helloWorld', + }, + })), + + # The job is still running when we poll. + create_http_response(json.dumps({ + 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', + 'status': 'RUNNING', + 'createdAt': 1483574581, + 'startedAt': 1483574589, + 'finishedAt': None, + 'command': 'helloWorld', + + 'helloWorldRequest': { + 'command': 'helloWorld', + }, + })), + + # The job has finished by the next polling request. + create_http_response(json.dumps({ + 'id': '70fef55d-6933-49fb-ae17-ec5d02bc9117', + 'status': 'FAILED', + 'createdAt': 1483574581, + 'startedAt': 1483574589, + 'finishedAt': 1483574604, + 'command': 'helloWorld', + + 'helloWorldRequest': { + 'command': 'helloWorld', + }, + + 'error': { + 'message': error_message, + }, + })), + ]) + + # noinspection PyUnusedLocal + def _send_http_request(*args, **kwargs): + return responses.popleft() + + mocked_sender = Mock(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) as context: + adapter.send_request({'command': 'helloWorld'}) + + self.assertEqual(text_type(context.exception), error_message) + + def test_regular_command_null_token(self): + """ + Sending commands to a sandbox that doesn't require authorization. + + This is generally not recommended, but the sandbox node may use + other methods to control access (e.g., listen only on loopback + interface, use IP address whitelist, etc.). + """ + # No access token. + adapter = SandboxAdapter('https://localhost', None) + + expected_result = { + 'message': 'Hello, IOTA!', + } + + mocked_response = create_http_response(json.dumps(expected_result)) + mocked_sender = Mock(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), + url = adapter.node_url, + + headers = { + # No auth token, so no Authorization header. + # 'Authorization': 'token ACCESS-TOKEN', + 'Content-type': 'application/json', + }, + ) + + 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. + """ + with self.assertRaises(TypeError): + # Nope; it has to be a unicode string. + SandboxAdapter('https://localhost', b'not valid') + + def test_error_auth_token_empty(self): + """ + ``auth_token`` is an empty string. + """ + with self.assertRaises(ValueError): + # If the node does not require authorization, use ``None``. + SandboxAdapter('https://localhost', '') + + def test_error_poll_interval_null(self): + """ + ``poll_interval`` is ``None``. + + The implications of allowing this are cool to think about... + but not implemented yet. + """ + with self.assertRaises(TypeError): + # noinspection PyTypeChecker + SandboxAdapter('https://localhost', 'token', None) + + def test_error_poll_interval_wrong_type(self): + """ + ``poll_interval`` is not an int 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) 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 4389005..5a69a3f 100644 --- a/test/adapter_test.py +++ b/test/adapter_test.py @@ -7,11 +7,10 @@ from unittest import TestCase import requests -from mock import Mock, patch -from six import BytesIO, text_type as text - -from iota import BadApiResponse, DEFAULT_PORT, InvalidUri, TryteString +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 class ResolveAdapterTestCase(TestCase): @@ -25,18 +24,18 @@ def test_adapter_instance(self): adapter = MockAdapter() self.assertIs(resolve_adapter(adapter), adapter) - def test_udp(self): + def test_http(self): """ - Resolving a valid udp:// URI. + Resolving a valid ``http://`` URI. """ - adapter = resolve_adapter('udp://localhost:14265/') + adapter = resolve_adapter('http://localhost:14265/') self.assertIsInstance(adapter, HttpAdapter) - def test_http(self): + def test_https(self): """ - Resolving a valid http:// URI. + Resolving a valid ``https://`` URI. """ - adapter = resolve_adapter('http://localhost:14265/') + adapter = resolve_adapter('https://localhost:14265/') self.assertIsInstance(adapter, HttpAdapter) def test_missing_protocol(self): @@ -54,97 +53,50 @@ def test_unknown_protocol(self): resolve_adapter('foobar://localhost:14265') -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. - """ - 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('udp://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 create_http_response(content, status=200): + # type: (Text, int) -> requests.Response + """ + Creates an HTTP Response object for a test. - def test_configure_default_port_http(self): - """ - Implicitly use default HTTP port for HttpAdapter. - """ - adapter = HttpAdapter.configure('http://iotatoken.com/') + References: + - :py:meth:`requests.adapters.HTTPAdapter.build_response` + """ + response = requests.Response() - self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, 80) - self.assertEqual(adapter.path, '/') + response.encoding = 'utf-8' + response.status_code = status + response.raw = BytesIO(content.encode('utf-8')) - def test_configure_path(self): - """ - Specifying a different path for HttpAdapter. - """ - adapter = HttpAdapter.configure('http://iotatoken.com:443/node') + return response - self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, 443) - self.assertEqual(adapter.path, '/node') - def test_configure_custom_path_default_port(self): +class HttpAdapterTestCase(TestCase): + def test_http(self): """ - Configuring HttpAdapter to use a custom path but implicitly use - default port. + Configuring HttpAdapter using a valid ``http://`` URI. """ - adapter = HttpAdapter.configure('http://iotatoken.com/node') + uri = 'http://localhost:14265/' + adapter = HttpAdapter(uri) - self.assertEqual(adapter.host, 'iotatoken.com') - self.assertEqual(adapter.port, 80) - self.assertEqual(adapter.path, '/node') + self.assertEqual(adapter.node_url, uri) - def test_configure_default_path(self): + def test_https(self): """ - Implicitly use default path for HttpAdapter. + Configuring HttpAdapter using a valid ``https://`` URI. """ - adapter = HttpAdapter.configure('udp://example.com:8000') + uri = 'https://localhost:14265/' + adapter = HttpAdapter(uri) - self.assertEqual(adapter.host, 'example.com') - self.assertEqual(adapter.port, 8000) - self.assertEqual(adapter.path, '/') + self.assertEqual(adapter.node_url, uri) - def test_configure_default_port_and_path(self): + def test_ipv4_address(self): """ - Implicitly use default port and path for HttpAdapter. + Configuring an HttpAdapter using an IPv4 address. """ - adapter = HttpAdapter.configure('udp://localhost') + uri = 'http://127.0.0.1:8080/' + adapter = HttpAdapter(uri) - self.assertEqual(adapter.host, 'localhost') - self.assertEqual(adapter.port, DEFAULT_PORT) - self.assertEqual(adapter.path, '/') + self.assertEqual(adapter.node_url, uri) def test_configure_error_missing_protocol(self): """ @@ -165,27 +117,34 @@ 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): """ 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!', } - 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 @@ -199,14 +158,18 @@ 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' + error_message = 'Command \u0027helloWorld\u0027 is unknown' - mocked_response = self._create_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) @@ -215,21 +178,28 @@ 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): """ 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' + error_message = 'java.lang.ArrayIndexOutOfBoundsException: 4' - mocked_response = self._create_response(json.dumps({ - 'exception': 'java.lang.ArrayIndexOutOfBoundsException: 4', - 'duration': 16 - })) + mocked_response = create_http_response( + status = 500, + + content = json.dumps({ + 'exception': error_message, + 'duration': 16, + }), + ) mocked_sender = Mock(return_value=mocked_response) @@ -238,15 +208,44 @@ 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), + '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( + status = 429, + content = json.dumps(decoded_response), + ) + + 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), + '429 response from node: {decoded}'.format(decoded=decoded_response), + ) def test_empty_response(self): """ The response is empty. """ - adapter = HttpAdapter('localhost') + adapter = HttpAdapter('http://localhost:14265') - mocked_response = self._create_response('') + mocked_response = create_http_response('') mocked_sender = Mock(return_value=mocked_response) @@ -255,16 +254,16 @@ 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): """ 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) + mocked_response = create_http_response(invalid_response) mocked_sender = Mock(return_value=mocked_response) @@ -274,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, ) @@ -282,10 +281,10 @@ 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) + invalid_response = ['message', 'Hello, IOTA!'] + mocked_response = create_http_response(json.dumps(invalid_response)) mocked_sender = Mock(return_value=mocked_response) @@ -295,20 +294,24 @@ 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 - def test_trytes_in_request(self): + @staticmethod + def test_trytes_in_request(): """ 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. - 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): @@ -324,6 +327,8 @@ def test_trytes_in_request(self): }) mocked_sender.assert_called_once_with( + url = adapter.node_url, + payload = json.dumps({ 'command': 'helloWorld', @@ -333,22 +338,8 @@ def test_trytes_in_request(self): 'CCPCBDVC9DTCEAKDXC9D9DEARCWCPCBDVCTCEAHDWCTCEAKDCDFD9DSCSA', ], }), - ) - @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 + headers = { + 'Content-type': 'application/json', + }, + ) diff --git a/test/api_test.py b/test/api_test.py index 5b10d85..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 @@ -115,13 +115,23 @@ def test_registered_command(self): command = api.getNodeInfo self.assertIsInstance(command, GetNodeInfoCommand) - def test_custom_command(self): + def test_unregistered_command(self): + """ + Attempting to create an unsupported command. + """ + api = StrictIota(MockAdapter()) + + with self.assertRaises(InvalidCommand): + # noinspection PyStatementEffect + api.helloWorld + + def test_create_command(self): """ Preparing an experimental/undocumented command. """ 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.create_command('helloWorld') + + self.assertIsInstance(custom_command, CustomCommand) + self.assertEqual(custom_command.command, 'helloWorld') 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], }, ) 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],