diff --git a/iota/adapter/__init__.py b/iota/adapter/__init__.py index 63f8e2e..d5e77e9 100644 --- a/iota/adapter/__init__.py +++ b/iota/adapter/__init__.py @@ -1,6 +1,6 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, \ - unicode_literals + unicode_literals import json from abc import ABCMeta, abstractmethod as abstract_method @@ -10,28 +10,27 @@ from socket import getdefaulttimeout as get_default_timeout from typing import Container, Dict, List, Optional, Text, Tuple, Union -from requests import Response, codes, request, auth +from requests import Response, auth, codes, request from six import PY2, binary_type, iteritems, moves as compat, text_type, \ - with_metaclass + with_metaclass from iota.exceptions import with_context from iota.json import JsonEncoder __all__ = [ - 'API_VERSION', - 'AdapterSpec', - 'BadApiResponse', - 'InvalidUri', + 'API_VERSION', + 'AdapterSpec', + 'BadApiResponse', + 'InvalidUri', ] if PY2: - # Fix an error when importing this package using the ``imp`` library - # (note: ``imp`` is deprecated since Python 3.4 in favor of - # ``importlib``). - # https://docs.python.org/3/library/imp.html - # https://travis-ci.org/iotaledger/iota.lib.py/jobs/191974244 - __all__ = map(binary_type, __all__) - + # Fix an error when importing this package using the ``imp`` library + # (note: ``imp`` is deprecated since Python 3.4 in favor of + # ``importlib``). + # https://docs.python.org/3/library/imp.html + # https://travis-ci.org/iotaledger/iota.lib.py/jobs/191974244 + __all__ = map(binary_type, __all__) API_VERSION = '1' """ @@ -39,503 +38,513 @@ https://github.com/iotaledger/iota.lib.py/issues/84 """ - # 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 + # noinspection PyCompatibility,PyUnresolvedReferences + from urlparse import SplitResult else: - # noinspection PyCompatibility,PyUnresolvedReferences - from urllib.parse import SplitResult + # noinspection PyCompatibility,PyUnresolvedReferences + from urllib.parse import SplitResult class BadApiResponse(ValueError): - """ - Indicates that a non-success response was received from the node. - """ - pass + """ + Indicates that a non-success response was received from the node. + """ + pass class InvalidUri(ValueError): - """ - Indicates that an invalid URI was provided to `resolve_adapter`. - """ - pass + """ + Indicates that an invalid URI was provided to `resolve_adapter`. + """ + pass -adapter_registry = {} # type: Dict[Text, AdapterMeta] +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. - """ - if isinstance(uri, BaseAdapter): - return uri - - 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 = { - 'parsed': parsed, - 'uri': uri, - }, - ) - - try: - adapter_type = adapter_registry[parsed.scheme] - except KeyError: - raise with_context( - exc = InvalidUri('Unrecognized protocol {protocol!r}.'.format( - protocol = parsed.scheme, - )), - - context = { - 'parsed': parsed, - 'uri': uri, - }, - ) - - return adapter_type.configure(parsed) - - -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) - - if not is_abstract(cls): - for protocol in getattr(cls, 'supported_protocols', ()): - # Note that we will not overwrite existing registered adapters. - adapter_registry.setdefault(protocol, cls) - - def configure(cls, parsed): - # type: (Union[Text, SplitResult]) -> HttpAdapter + # type: (AdapterSpec) -> BaseAdapter """ - Creates a new instance using the specified URI. - - :param parsed: - Result of :py:func:`urllib.parse.urlsplit`. + Given a URI, returns a properly-configured adapter instance. """ - return cls(parsed) + if isinstance(uri, BaseAdapter): + return uri + parsed = compat.urllib_parse.urlsplit(uri) # type: SplitResult -class BaseAdapter(with_metaclass(AdapterMeta)): - """ - Interface for IOTA API adapters. - - Adapters make it easy to customize the way an StrictIota instance - communicates with a node. - """ - supported_protocols = () # type: Tuple[Text] - """ - 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 get_uri(self): - # type: () -> Text - """ - Returns the URI that this adapter will use. - """ - raise NotImplementedError( - 'Not implemented in {cls}.'.format(cls=type(self).__name__), - ) + if not parsed.scheme: + raise with_context( + exc=InvalidUri( + 'URI must begin with "://" (e.g., "udp://").', + ), - @abstract_method - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - """ - Sends an API request to the node. + context={ + 'parsed': parsed, + 'uri': uri, + }, + ) - :param payload: - JSON payload. - - :param kwargs: - Additional keyword arguments for the adapter. - - :return: - Decoded response from the node. - - :raise: - - :py:class:`BadApiResponse` if a non-success response was - received. - """ - raise NotImplementedError( - '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 + try: + adapter_type = adapter_registry[parsed.scheme] + except KeyError: + raise with_context( + exc=InvalidUri('Unrecognized protocol {protocol!r}.'.format( + protocol=parsed.scheme, + )), - 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 {}}) + context={ + 'parsed': parsed, + 'uri': uri, + }, + ) + return adapter_type.configure(parsed) -class HttpAdapter(BaseAdapter): - """ - Sends standard HTTP requests. - """ - supported_protocols = ('http', 'https',) - - DEFAULT_HEADERS = { - 'Content-type': 'application/json', - - # https://github.com/iotaledger/iota.lib.py/issues/84 - 'X-IOTA-API-Version': API_VERSION, - } - """ - Default headers sent with every request. - These can be overridden on a per-request basis, by specifying values - in the ``headers`` kwarg. - """ - - def __init__(self, uri, timeout=None, authentication=None): - # type: (Union[Text, SplitResult], Optional[int]) -> None - super(HttpAdapter, self).__init__() - - self.timeout = timeout - self.authentication = authentication - - if isinstance(uri, text_type): - uri = compat.urllib_parse.urlsplit(uri) # type: SplitResult - - if uri.scheme not in self.supported_protocols: - raise with_context( - exc = InvalidUri('Unsupported protocol {protocol!r}.'.format( - protocol = uri.scheme, - )), - - context = { - 'uri': uri, - }, - ) - - if not uri.hostname: - raise with_context( - exc = InvalidUri( - 'Empty hostname in URI {uri!r}.'.format( - uri = uri.geturl(), - ), - ), - - context = { - 'uri': uri, - }, - ) - try: - # noinspection PyStatementEffect - uri.port - except ValueError: - raise with_context( - exc = InvalidUri( - 'Non-numeric port in URI {uri!r}.'.format( - uri = uri.geturl(), - ), - ), - - context = { - 'uri': uri, - }, - ) - - self.uri = uri - - @property - def node_url(self): - # type: () -> Text +class AdapterMeta(ABCMeta): """ - Returns the node URL. + Automatically registers new adapter classes in ``adapter_registry``. """ - return self.uri.geturl() - def get_uri(self): - # type: () -> Text - return self.uri.geturl() + # noinspection PyShadowingBuiltins + def __init__(cls, what, bases=None, dict=None): + super(AdapterMeta, cls).__init__(what, bases, dict) - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - kwargs.setdefault('headers', {}) - for key, value in iteritems(self.DEFAULT_HEADERS): - kwargs['headers'].setdefault(key, value) + if not is_abstract(cls): + for protocol in getattr(cls, 'supported_protocols', ()): + # Note that we will not overwrite existing registered + # adapters. + adapter_registry.setdefault(protocol, cls) - response = self._send_http_request( - # Use a custom JSON encoder that knows how to convert Tryte values. - payload = JsonEncoder().encode(payload), + def configure(cls, parsed): + # type: (Union[Text, SplitResult]) -> HttpAdapter + """ + Creates a new instance using the specified URI. - url = self.node_url, - **kwargs - ) + :param parsed: + Result of :py:func:`urllib.parse.urlsplit`. + """ + return cls(parsed) - 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 +class BaseAdapter(with_metaclass(AdapterMeta)): """ - Sends the actual HTTP request. + Interface for IOTA API adapters. - Split into its own method so that it can be mocked during unit - tests. + Adapters make it easy to customize the way an StrictIota instance + communicates with a node. """ - - default_timeout = self.timeout if self.timeout else get_default_timeout() - kwargs.setdefault('timeout', default_timeout) - if self.authentication: - kwargs.setdefault('auth', auth.HTTPBasicAuth(*self.authentication)) - - 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 + supported_protocols = () # type: Tuple[Text] + """ + Protocols that ``resolve_adapter`` can use to identify this adapter + type. """ - Interprets the HTTP response from the node. - :param response: - The response object received from :py:meth:`_send_http_request`. + def __init__(self): + super(BaseAdapter, self).__init__() + + self._logger = None # type: Logger + + @abstract_method + def get_uri(self): + # type: () -> Text + """ + Returns the URI that this adapter will use. + """ + raise NotImplementedError( + 'Not implemented in {cls}.'.format(cls=type(self).__name__), + ) + + @abstract_method + def send_request(self, payload, **kwargs): + # type: (dict, dict) -> dict + """ + Sends an API request to the node. + + :param payload: + JSON payload. + + :param kwargs: + Additional keyword arguments for the adapter. + + :return: + Decoded response from the node. + + :raise: + - :py:class:`BadApiResponse` if a non-success response was + received. + """ + raise NotImplementedError( + '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 {}}) - :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. +class HttpAdapter(BaseAdapter): + """ + Sends standard HTTP requests. """ - raw_content = response.text - if not raw_content: - raise with_context( - exc = BadApiResponse( - 'Empty {status} response from node.'.format( - status = response.status_code, - ), - ), - - context = { - 'request': payload, - }, - ) + supported_protocols = ('http', 'https',) - try: - decoded = json.loads(raw_content) # type: dict - # :bc: py2k doesn't have JSONDecodeError - except ValueError: - raise with_context( - exc = BadApiResponse( - 'Non-JSON {status} response from node: {raw_content}'.format( - status = response.status_code, - raw_content = raw_content, - ) - ), - - context = { - 'request': payload, - 'raw_response': raw_content, - }, - ) - - if not isinstance(decoded, dict): - raise with_context( - exc = BadApiResponse( - 'Malformed {status} response from node: {decoded!r}'.format( - status = response.status_code, - decoded = decoded, - ), - ), - - context = { - 'request': payload, - 'response': decoded, - }, - ) - - if response.status_code in expected_status: - 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 + DEFAULT_HEADERS = { + 'Content-type': 'application/json', - raise with_context( - exc = BadApiResponse( - '{status} response from node: {error}'.format( - error = error or decoded, - status = response.status_code, - ), - ), + # https://github.com/iotaledger/iota.lib.py/issues/84 + 'X-IOTA-API-Version': API_VERSION, + } + """ + Default headers sent with every request. + These can be overridden on a per-request basis, by specifying values + in the ``headers`` kwarg. + """ - context = { - 'request': payload, - 'response': decoded, - }, - ) + def __init__(self, uri, timeout=None, authentication=None): + # type: (Union[Text, SplitResult], Optional[int]) -> None + super(HttpAdapter, self).__init__() + + self.timeout = timeout + self.authentication = authentication + + if isinstance(uri, text_type): + uri = compat.urllib_parse.urlsplit(uri) # type: SplitResult + + if uri.scheme not in self.supported_protocols: + raise with_context( + exc=InvalidUri('Unsupported protocol {protocol!r}.'.format( + protocol=uri.scheme, + )), + + context={ + 'uri': uri, + }, + ) + + if not uri.hostname: + raise with_context( + exc=InvalidUri( + 'Empty hostname in URI {uri!r}.'.format( + uri=uri.geturl(), + ), + ), + + context={ + 'uri': uri, + }, + ) + + try: + # noinspection PyStatementEffect + uri.port + except ValueError: + raise with_context( + exc=InvalidUri( + 'Non-numeric port in URI {uri!r}.'.format( + uri=uri.geturl(), + ), + ), + + context={ + 'uri': uri, + }, + ) + + self.uri = uri + + @property + def node_url(self): + # type: () -> Text + """ + Returns the node URL. + """ + return self.uri.geturl() + + def get_uri(self): + # type: () -> Text + return self.uri.geturl() + + def send_request(self, payload, **kwargs): + # type: (dict, dict) -> dict + kwargs.setdefault('headers', {}) + for key, value in iteritems(self.DEFAULT_HEADERS): + kwargs['headers'].setdefault(key, value) + + 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', + self.timeout if self.timeout else get_default_timeout(), + ) + + if self.authentication: + kwargs.setdefault('auth', auth.HTTPBasicAuth(*self.authentication)) + + 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( + exc=BadApiResponse( + 'Empty {status} response from node.'.format( + status=response.status_code, + ), + ), + + context={ + 'request': payload, + }, + ) + + try: + decoded = json.loads(raw_content) # type: dict + # :bc: py2k doesn't have JSONDecodeError + except ValueError: + raise with_context( + exc=BadApiResponse( + 'Non-JSON {status} response from node: ' + '{raw_content}'.format( + status=response.status_code, + raw_content=raw_content, + ) + ), + + context={ + 'request': payload, + 'raw_response': raw_content, + }, + ) + + if not isinstance(decoded, dict): + raise with_context( + exc=BadApiResponse( + 'Malformed {status} response from node: {decoded!r}'.format( + status=response.status_code, + decoded=decoded, + ), + ), + + context={ + 'request': payload, + 'response': decoded, + }, + ) + + if response.status_code in expected_status: + 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): - """ - An mock adapter used for simulating API responses. - - To use this adapter, you must first "seed" the responses that the - adapter should return for each request. The adapter will then return - the appropriate seeded response each time it "sends" a request. - """ - supported_protocols = ('mock',) - - # noinspection PyUnusedLocal - @classmethod - def configure(cls, uri): - return cls() - - def __init__(self): - super(MockAdapter, self).__init__() - - self.responses = {} # type: Dict[Text, deque] - self.requests = [] # type: List[dict] - - def get_uri(self): - return 'mock://' - - def seed_response(self, command, response): - # type: (Text, dict) -> MockAdapter """ - Sets the response that the adapter will return for the specified - command. - - You can seed multiple responses per command; the adapter will put - them into a FIFO queue. When a request comes in, the adapter will - pop the corresponding response off of the queue. - - Example:: - - adapter.seed_response('sayHello', {'message': 'Hi!'}) - adapter.seed_response('sayHello', {'message': 'Hello!'}) - - adapter.send_request({'command': 'sayHello'}) - # {'message': 'Hi!'} + An mock adapter used for simulating API responses. - adapter.send_request({'command': 'sayHello'}) - # {'message': 'Hello!'} + To use this adapter, you must first "seed" the responses that the + adapter should return for each request. The adapter will then return + the appropriate seeded response each time it "sends" a request. """ - if command not in self.responses: - self.responses[command] = deque() - - self.responses[command].append(response) - return self - - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - # Store a snapshot so that we can inspect the request later. - self.requests.append(dict(payload)) - - command = payload['command'] - - try: - response = self.responses[command].popleft() - except KeyError: - raise with_context( - exc = BadApiResponse( - 'No seeded response for {command!r} ' - '(expected one of: {seeds!r}).'.format( - command = command, - seeds = list(sorted(self.responses.keys())), - ), - ), - - context = { - 'request': payload, - }, - ) - except IndexError: - raise with_context( - exc = BadApiResponse( - '{command} called too many times; no seeded responses left.'.format( - command = command, - ), - ), - - context = { - 'request': payload, - }, - ) - - error = response.get('exception') or response.get('error') - if error: - raise with_context(BadApiResponse(error), context={'request': payload}) - - return response + supported_protocols = ('mock',) + + # noinspection PyUnusedLocal + @classmethod + def configure(cls, uri): + return cls() + + def __init__(self): + super(MockAdapter, self).__init__() + + self.responses = {} # type: Dict[Text, deque] + self.requests = [] # type: List[dict] + + def get_uri(self): + return 'mock://' + + def seed_response(self, command, response): + # type: (Text, dict) -> MockAdapter + """ + Sets the response that the adapter will return for the specified + command. + + You can seed multiple responses per command; the adapter will + put them into a FIFO queue. When a request comes in, the + adapter will pop the corresponding response off of the queue. + + Example: + + .. code-block:: python + + adapter.seed_response('sayHello', {'message': 'Hi!'}) + adapter.seed_response('sayHello', {'message': 'Hello!'}) + + adapter.send_request({'command': 'sayHello'}) + # {'message': 'Hi!'} + + adapter.send_request({'command': 'sayHello'}) + # {'message': 'Hello!'} + """ + if command not in self.responses: + self.responses[command] = deque() + + self.responses[command].append(response) + return self + + def send_request(self, payload, **kwargs): + # type: (dict, dict) -> dict + # Store a snapshot so that we can inspect the request later. + self.requests.append(dict(payload)) + + command = payload['command'] + + try: + response = self.responses[command].popleft() + except KeyError: + raise with_context( + exc=BadApiResponse( + 'No seeded response for {command!r} ' + '(expected one of: {seeds!r}).'.format( + command=command, + seeds=list(sorted(self.responses.keys())), + ), + ), + + context={ + 'request': payload, + }, + ) + except IndexError: + raise with_context( + exc=BadApiResponse( + '{command} called too many times; ' + 'no seeded responses left.'.format( + command=command, + ), + ), + + context={ + 'request': payload, + }, + ) + + error = response.get('exception') or response.get('error') + if error: + raise with_context(BadApiResponse(error), + context={'request': payload}) + + return response