diff --git a/tests/core/method-class/test_method.py b/tests/core/method-class/test_method.py new file mode 100644 index 0000000000..4e9db4ea5f --- /dev/null +++ b/tests/core/method-class/test_method.py @@ -0,0 +1,281 @@ +from inspect import ( + isclass, +) +import pytest + +from eth_utils.toolz import ( + identity, + pipe, +) + +from web3 import ( + EthereumTesterProvider, + Web3, +) +from web3.method import ( + Method, +) +from web3.module import ( + ModuleV2, +) + + +def test_method_accepts_callable_for_selector(): + method = Method( + mungers=[], + json_rpc_method=lambda *_: 'eth_method', + formatter_lookup_fn='' + ) + assert method.method_selector_fn() == 'eth_method' + + +def test_method_selector_fn_accepts_str(): + method = Method( + mungers=[], + json_rpc_method='eth_method', + formatter_lookup_fn='' + ) + assert method.method_selector_fn() == 'eth_method' + + +def test_method_selector_fn_invalid_arg(): + with pytest.raises(ValueError): + method = Method( + mungers=[], + json_rpc_method=555555, + formatter_lookup_fn='' + ) + method.method_selector_fn() + + +def test_get_formatters_default_formatter_for_falsy_config(): + method = Method( + mungers=[], + json_rpc_method='eth_method', + formatter_lookup_fn='' + ) + + default_input_formatters, default_output_formatters = method.get_formatters('') + + assert pipe(['a', 'b', 'c'], *default_input_formatters) == ['a', 'b', 'c'] + assert pipe(['a', 'b', 'c'], *default_output_formatters) == ['a', 'b', 'c'] + + +def test_get_formatters_non_falsy_config_retrieval(): + def formatter_lookup_fn(method): + if method == 'eth_method': + return 'match' + return 'nonmatch' + method = Method( + mungers=[], + json_rpc_method='eth_method', + formatter_lookup_fn=formatter_lookup_fn, + ) + assert method.get_formatters('eth_method') == 'match' + assert method.get_formatters('eth_nonmatching') == 'nonmatch' + + +def test_input_munger_parameter_passthrough_matching_arity(): + method = Method( + mungers=[lambda m, z, y: ['success']], + json_rpc_method='eth_method', + formatter_lookup_fn='' + ) + method.input_munger((object(), ['first', 'second'], {})) == 'success' + + +def test_input_munger_parameter_passthrough_mismatch_arity(): + method = Method( + mungers=[lambda m, z, y: 'success'], + json_rpc_method='eth_method', + formatter_lookup_fn='' + ) + with pytest.raises(TypeError): + method.input_munger((object(), ['first', 'second', 'third'], {})) + + +def test_input_munger_falsy_config_result_in_default_munger(): + method = Method( + mungers=[], + json_rpc_method='eth_method', + formatter_lookup_fn='' + ) + method.input_munger((object(), [], {})) == [] + + +def test_default_input_munger_with_input_parameters_exception(): + method = Method( + mungers=[], + json_rpc_method='eth_method', + formatter_lookup_fn='' + ) + with pytest.raises(TypeError): + method.input_munger((object(), [1], {})) + + +def get_test_formatters(method): + def formatter(params): + return ['ok'] + + if method == 'eth_method': + return ([formatter], [identity]) + + +@pytest.mark.parametrize( + "method_config,args,kwargs,expected_result", + ( + ( + { + 'mungers': [], + 'formatter_lookup_fn': '' + }, + [], + {}, + ValueError + ), + ( + { + 'mungers': [], + 'json_rpc_method': 'eth_method', + 'formatter_lookup_fn': '' + }, + ['unexpected_argument'], + {}, + TypeError + ), + ( + { + 'mungers': [], + 'json_rpc_method': 'eth_method', + 'formatter_lookup_fn': '' + }, + [], + {}, + ('eth_method', ()) + ), + ( + { + 'mungers': [], + 'json_rpc_method': lambda *_: 'eth_method', + 'formatter_lookup_fn': '' + }, + [], + {}, + ('eth_method', ()) + ), + ( + { + 'mungers': [ + lambda m, x, y, z: [x, y], + lambda m, x, y: [x], + lambda m, x: [str(x)]], + 'json_rpc_method': 'eth_method', + 'formatter_lookup_fn': '' + }, + [1, 2, 3], + {}, + ('eth_method', ["1"]) + ), + ( + { + 'mungers': [ + lambda m, x, y, z: [x, y], + lambda m, x, y: [x], + lambda m, x: [str(x)]], + 'json_rpc_method': 'eth_method', + 'formatter_lookup_fn': '' + }, + [1, 2, 3, 4], + {}, + TypeError, + ), + ( + { + 'mungers': [], + 'json_rpc_method': 'eth_method', + 'formatter_lookup_fn': get_test_formatters + }, + [], + {}, + ('eth_method', ['ok']) + ), + ( + { + 'mungers': [], + 'json_rpc_method': 'eth_mismatch', + 'formatter_lookup_fn': get_test_formatters + }, + [], + {}, + ('eth_mismatch', ()) + ), + ( + { + 'mungers': [ + lambda m, x, y, z: [x, y], + lambda m, x, y: [x], + lambda m, x: [str(x)]], + 'json_rpc_method': 'eth_method', + 'formatter_lookup_fn': get_test_formatters + }, + [1, 2, 3], + {}, + ('eth_method', ['ok']) + ), + ) +) +def test_process_params( + method_config, + args, + kwargs, + expected_result,): + + if isclass(expected_result) and issubclass(expected_result, Exception): + with pytest.raises(expected_result): + method = Method(**method_config) + req_params, output_formatter = method.process_params(object(), *args, **kwargs) + else: + method = Method(**method_config) + req_params, output_formatter = method.process_params(object(), *args, **kwargs) + assert req_params == expected_result + + +def keywords(module, keyword_one, keyword_two): + return module, [keyword_one, keyword_two] + + +class Success(Exception): + pass + + +def return_exception_raising_formatter(method): + def formatter(params): + raise Success() + return ([formatter], []) + + +class FakeModule(ModuleV2): + method = Method( + 'eth_method', + mungers=[keywords], + formatter_lookup_fn=return_exception_raising_formatter) + + +@pytest.fixture +def dummy_w3(): + return Web3( + EthereumTesterProvider(), + modules={'fake': FakeModule}, + middlewares=[]) + + +def test_munger_class_method_access_raises_friendly_error(): + with pytest.raises(TypeError): + FakeModule.method(1, 2) + + +def test_munger_arguments_by_keyword(dummy_w3): + with pytest.raises(Success): + dummy_w3.fake.method(keyword_one=1, keyword_two=2) + with pytest.raises(Success): + dummy_w3.fake.method(1, keyword_two=2) diff --git a/tests/core/version-module/test_version_module.py b/tests/core/version-module/test_version_module.py new file mode 100644 index 0000000000..7f3e2d1475 --- /dev/null +++ b/tests/core/version-module/test_version_module.py @@ -0,0 +1,52 @@ +import pytest + +from web3 import ( + EthereumTesterProvider, + Web3, +) +from web3.providers.eth_tester.main import ( + AsyncEthereumTesterProvider, +) +from web3.version import ( + AsyncVersion, + BlockingVersion, + Version, +) + + +@pytest.fixture +def blocking_w3(): + return Web3( + EthereumTesterProvider(), + modules={ + 'blocking_version': BlockingVersion, + 'legacy_version': Version + }) + + +@pytest.fixture +def async_w3(): + return Web3( + AsyncEthereumTesterProvider(), + middlewares=[], + modules={ + 'async_version': AsyncVersion, + }) + + +def test_blocking_version(blocking_w3): + assert blocking_w3.blocking_version.api == blocking_w3.legacy_version.api + assert blocking_w3.blocking_version.node == blocking_w3.legacy_version.node + assert blocking_w3.blocking_version.ethereum == blocking_w3.legacy_version.ethereum + + +@pytest.mark.asyncio +async def test_async_blocking_version(async_w3, blocking_w3): + assert async_w3.async_version.api == blocking_w3.legacy_version.api + + assert await async_w3.async_version.node == blocking_w3.legacy_version.node + with pytest.raises( + ValueError, + message="RPC Endpoint has not been implemented: eth_protocolVersion" + ): + assert await async_w3.async_version.ethereum == blocking_w3.legacy_version.ethereum diff --git a/web3/exceptions.py b/web3/exceptions.py index 406aacae41..b651683de2 100644 --- a/web3/exceptions.py +++ b/web3/exceptions.py @@ -58,13 +58,6 @@ def __str__(self): return self.args[0] -class UnhandledRequest(Exception): - """ - Raised by the manager when none of it's providers responds to a request. - """ - pass - - class MismatchedABI(Exception): """ Raised when an ABI does not match with supplied parameters, or when an diff --git a/web3/manager.py b/web3/manager.py index 08f30640b6..e7481bb61c 100644 --- a/web3/manager.py +++ b/web3/manager.py @@ -1,15 +1,15 @@ import logging import uuid +from web3._utils.decorators import ( + deprecated_for, +) from web3._utils.threads import ( spawn, ) from web3.datastructures import ( NamedElementOnion, ) -from web3.exceptions import ( - UnhandledRequest, -) from web3.middleware import ( abi_middleware, attrdict_middleware, @@ -74,19 +74,18 @@ def default_middlewares(web3): # Provider requests and response # def _make_request(self, method, params): - if self.provider: - request_func = self.provider.request_func(self.web3, tuple(self.middleware_onion)) - self.logger.debug("Making request. Method: %s", method) - return request_func(method, params) - else: - raise UnhandledRequest( - "No provider available to respond to the RPC request:\n" - "method:{0}\n" - "params:{1}\n".format( - method, - params, - ) - ) + request_func = self.provider.request_func( + self.web3, + tuple(self.middleware_onion)) + self.logger.debug("Making request. Method: %s", method) + return request_func(method, params) + + async def _coro_make_request(self, method, params): + request_func = self.provider.request_func( + self.web3, + tuple(self.middleware_onion)) + self.logger.debug("Making request. Method: %s", method) + return await request_func(method, params) def request_blocking(self, method, params): """ @@ -99,6 +98,18 @@ def request_blocking(self, method, params): return response['result'] + async def coro_request(self, method, params): + """ + Couroutine for making a request using the provider + """ + response = await self._coro_make_request(method, params) + + if "error" in response: + raise ValueError(response["error"]) + + return response['result'] + + @deprecated_for("coro_request") def request_async(self, raw_method, raw_params): request_id = uuid.uuid4() self.pending_requests[request_id] = spawn( diff --git a/web3/method.py b/web3/method.py new file mode 100644 index 0000000000..cbe5a96633 --- /dev/null +++ b/web3/method.py @@ -0,0 +1,148 @@ +import functools + +from eth_utils import ( + to_tuple, +) +from eth_utils.toolz import ( + identity, + pipe, +) + + +def _munger_star_apply(fn): + @functools.wraps(fn) + def inner(args): + return fn(*args) + return inner + + +def get_default_formatters(*args, **kwargs): + return ([identity], [identity],) + + +def default_munger(module, *args, **kwargs): + if not args and not kwargs: + return tuple() + else: + raise TypeError("Parameters passed to method without parameter mungers defined.") + + +class Method: + """Method object for web3 module methods + + Calls to the Method go through these steps: + + 1. input munging - includes normalization, parameter checking, early parameter + formatting. Any processing on the input parameters that need to happen before + json_rpc method string selection occurs. + + A note about mungers: The first (root) munger should reflect the desired + api function arguments. In other words, if the api function wants to + behave as: `getBalance(account, block_identifier=None)`, the root munger + should accept these same arguments, with the addition of the module as + the first argument e.g.: + + ``` + def getBalance_root_munger(module, account, block_identifier=None): + if block_identifier is None: + block_identifier = DEFAULT_BLOCK + return module, [account, block_identifier] + ``` + + all mungers should return an argument list. + + if no munger is provided, a default munger expecting no method arguments + will be used. + + 2. method selection - The json_rpc_method argument can be method string or a + function that returns a method string. If a callable is provided the processed + method inputs are passed to the method selection function, and the returned + method string is used. + + 3. request and response formatters are retrieved - formatters are retrieved + using the json rpc method string. The lookup function provided by the + formatter_lookup_fn configuration is passed the method string and is + expected to return a 2-tuple of lists containing the + request_formatters and response_formatters in that order. + e.g. ([*request_formatters], [*response_formatters]). + + 4. After the parameter processing from steps 1-3 the request is made using + the calling function returned by the module attribute ``retrieve_caller_fn`` + and the reponse formatters are applied to the output. + """ + def __init__( + self, + json_rpc_method=None, + mungers=None, + formatter_lookup_fn=None, + web3=None): + + self.json_rpc_method = json_rpc_method + self.mungers = mungers or [default_munger] + self.formatter_lookup_fn = formatter_lookup_fn or get_default_formatters + + def __get__(self, obj=None, obj_type=None): + if obj is None: + raise TypeError( + "Direct calls to methods are not supported. " + "Methods must be called from an module instance, " + "usually attached to a web3 instance.") + return obj.retrieve_caller_fn(self) + + @property + def method_selector_fn(self): + """Gets the method selector from the config. + """ + if callable(self.json_rpc_method): + return self.json_rpc_method + elif isinstance(self.json_rpc_method, (str,)): + return lambda *_: self.json_rpc_method + raise ValueError("``json_rpc_method`` config invalid. May be a string or function") + + def get_formatters(self, method_string): + """Lookup the request formatters for the rpc_method + + The lookup_fn output is expected to be a 2 length tuple of lists of + the request and output formatters, respectively. + """ + formatters = self.formatter_lookup_fn(method_string) + return formatters or get_default_formatters() + + def input_munger(self, val): + try: + module, args, kwargs = val + except TypeError: + raise ValueError("input_munger expects a 3-tuple") + + # TODO: Create friendly error output. + mungers_iter = iter(self.mungers) + root_munger = next(mungers_iter) + munged_inputs = pipe( + root_munger(module, *args, **kwargs), + *map(lambda m: _munger_star_apply(functools.partial(m, module)), mungers_iter)) + + return munged_inputs + + def process_params(self, module, *args, **kwargs): + # takes in input params, steps 1-3 + params, method, (req_formatters, ret_formatters) = _pipe_and_accumulate( + (module, args, kwargs,), + [self.input_munger, self.method_selector_fn, self.get_formatters]) + + return (method, pipe(params, *req_formatters)), ret_formatters + + +@to_tuple +def _pipe_and_accumulate(val, fns): + """pipes val through a list of fns while accumulating results from + each function, returning a tuple. + + e.g.: + + >>> _pipe_and_accumulate([lambda x: x**2, lambda x: x*10], 5) + (25, 250) + + """ + for fn in fns: + val = fn(val) + yield val diff --git a/web3/module.py b/web3/module.py index cd9b1f8d5d..45d3b3bd98 100644 --- a/web3/module.py +++ b/web3/module.py @@ -1,3 +1,29 @@ +from eth_utils.toolz import ( + curry, + pipe, +) + + +@curry +def retrieve_blocking_method_call_fn(w3, module, method): + def caller(*args, **kwargs): + (method_str, params), output_formatters = method.process_params(module, *args, **kwargs) + return pipe( + w3.manager.request_blocking(method_str, params), + *output_formatters) + return caller + + +@curry +def retrieve_async_method_call_fn(w3, module, method): + async def caller(*args, **kwargs): + (method_str, params), output_formatters = method.process_params(module, *args, **kwargs) + raw_result = await w3.manager.coro_request(method_str, params) + return pipe(raw_result, *output_formatters) + return caller + + +# TODO: Replace this with ModuleV2 when ready. class Module: web3 = None @@ -24,3 +50,17 @@ def attach(cls, target, module_name=None): web3 = target setattr(target, module_name, cls(web3)) + + +# Module should no longer have access to the full web3 api. +# Only the calling functions need access to the request methods. +# Any "re-entrant" shinanigans can go in the middlewares, which do +# have web3 access. +class ModuleV2(Module): + is_async = False + + def __init__(self, web3): + if self.is_async: + self.retrieve_caller_fn = retrieve_async_method_call_fn(web3, self) + else: + self.retrieve_caller_fn = retrieve_blocking_method_call_fn(web3, self) diff --git a/web3/providers/auto.py b/web3/providers/auto.py index cd8e109f31..95a6c5807e 100644 --- a/web3/providers/auto.py +++ b/web3/providers/auto.py @@ -78,7 +78,12 @@ def isConnected(self): def _proxy_request(self, method, params, use_cache=True): provider = self._get_active_provider(use_cache) if provider is None: - raise CannotHandleRequest("Could not discover provider") + raise CannotHandleRequest( + "Could not discover provider while making request: " + "method:{0}\n" + "params:{1}\n".format( + method, + params)) return provider.make_request(method, params) diff --git a/web3/providers/eth_tester/main.py b/web3/providers/eth_tester/main.py index 25e3e9f644..af86ba5ae5 100644 --- a/web3/providers/eth_tester/main.py +++ b/web3/providers/eth_tester/main.py @@ -8,6 +8,19 @@ ) +class AsyncEthereumTesterProvider(BaseProvider): + """This is a placeholder. + + For now its purpose is to provide an awaitable request function + for testing the async api execution. + """ + def __init__(self): + self.eth_tester = EthereumTesterProvider() + + async def make_request(self, method, params): + return self.eth_tester.make_request(method, params) + + class EthereumTesterProvider(BaseProvider): middlewares = [ default_transaction_fields_middleware, diff --git a/web3/version.py b/web3/version.py index 2586c8bb6d..37bf7f9df5 100644 --- a/web3/version.py +++ b/web3/version.py @@ -1,8 +1,46 @@ +from web3.method import ( + Method, +) from web3.module import ( Module, + ModuleV2, ) +class BaseVersion(ModuleV2): + retrieve_caller_fn = None + + _get_node_version = Method('web3_clientVersion') + _get_protocol_version = Method('eth_protocolVersion') + + @property + def api(self): + from web3 import __version__ + return __version__ + + +class AsyncVersion(BaseVersion): + is_async = True + + @property + async def node(self): + return await self._get_node_version() + + @property + async def ethereum(self): + return await self._get_protocol_version() + + +class BlockingVersion(BaseVersion): + @property + def node(self): + return self._get_node_version() + + @property + def ethereum(self): + return self._get_protocol_version() + + class Version(Module): @property def api(self):