diff --git a/tests/core/base-module/test_base_module.py b/tests/core/base-module/test_base_module.py new file mode 100644 index 0000000000..d70926227d --- /dev/null +++ b/tests/core/base-module/test_base_module.py @@ -0,0 +1,21 @@ +import pytest + +from web3.module import Module + + +class DummyWeb3: + def __init__(self): + Module.attach(self, 'module') + + +@pytest.fixture(scope='module') +def dw3(): + w3 = DummyWeb3() + return w3 + + +def test_module_lookup_method(dw3): + with pytest.raises(AttributeError): + assert dw3.module.blahblahblah + dw3.module.lookup_method_fn = lambda *_: True + assert dw3.module.blahblahblah is True 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..e77cee5a2c --- /dev/null +++ b/tests/core/version-module/test_version_module.py @@ -0,0 +1,52 @@ +import pytest + +from web3 import Web3, EthereumTesterProvider +from web3.providers.eth_tester.main import AsyncEthereumTesterProvider +from web3.version import BlockingVersion, AsyncVersion, 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.network == blocking_w3.legacy_version.network + assert blocking_w3.blocking_version.ethereum == blocking_w3.legacy_version.ethereum + + +@pytest.mark.asyncio +async def test_async_blocking_version(async_w3, blocking_w3): + # This seems a little awkward. How do we know if something is an awaitable + # or a static method? + 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: net_version" + ): + # net_version is provided through a middleware + assert await async_w3.async_version.network == blocking_w3.legacy_version.network + 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..f007bea3cb 100644 --- a/web3/exceptions.py +++ b/web3/exceptions.py @@ -114,3 +114,7 @@ class TimeExhausted(Exception): Raised when a method has not retrieved the desired result within a specified timeout. """ pass + + +class UndefinedMethodError(Exception): + pass diff --git a/web3/method.py b/web3/method.py new file mode 100644 index 0000000000..ec3b2aac09 --- /dev/null +++ b/web3/method.py @@ -0,0 +1,144 @@ +from eth_utils import ( + to_tuple, +) +from eth_utils.toolz import ( + identity, + pipe, +) + +from web3.exceptions import ( + UndefinedMethodError, +) + +TEST_METHOD_CONFIG = { + 'name': "signInBlood", + 'mungers': [], + 'json_rpc_method': "eth_signTransactionInBlood", +} + + +def lookup_method(module, module_config, method_class, attr_name): + try: + method = module_config[attr_name] + except KeyError: + raise UndefinedMethodError("No method named {0}".format(attr_name)) + return method_class(module.web3, method) + + +class DummyRequestManager: + def request_blocking(method, params): + return (method, params) + + +class DummyWeb3: + manager = DummyRequestManager + + +def test_sync_method_config_loading(): + signInBlood = BlockingMethod(DummyWeb3(), TEST_METHOD_CONFIG) + signInBlood.input_munger = lambda *_: {} + assert signInBlood.method_selector + assert ('eth_signTransactionInBlood', {}) == signInBlood() + + +class BaseMethod: + """BaseMethod for web3 module methods + + Calls to the Method go through these steps: + + 1. input munging ;) - includes normalization, parameter checking, formatters. + Any processing on the input parameters that need to happen before json_rpc + method string selection occurs. + + 2. method selection - function that selects the correct rpc_method. accepts a + function or an string. + + 3. constructing formatter middlewares - takes the rpc_method and looks up the + corresponding input/output formatters. these are the middlewares migrated here. + + 4. making the request through the middleware (pipeline)? wrapped request + function. + """ + def __init__(self, web3, method_config): + self.__name__ = method_config.get('name', 'anonymous') + self.__doc__ = method_config.get('doc', '') + self.is_property = method_config.get('is_property', False) + self.web3 = web3 + self.input_munger = self._construct_input_pipe( + method_config.get('mungers')) or identity + self.method_selector = self._method_selector( + method_config.get('json_rpc_method')) + # TODO: Write formatter lookup. + self.lookup_formatter = None + + def _construct_input_pipe(self, formatters): + formatters = formatters or [identity] + + def _method_selector(self, selector): + """Method selector can just be the method string. + """ + if isinstance(selector, (str,)): + return lambda _: selector + else: + return selector + + def get_formatters(self, method_string): + """Lookup the request formatters for the rpc_method""" + if not self.lookup_formatter: + return ([identity], [identity],) + else: + raise NotImplementedError() + + def prep_for_call(self, *args, **kwargs): + # takes in input params, steps 1-3 + params, method, (req_formatters, ret_formatters) = pipe_appends( + [self.input_munger, self.method_selector, self.get_formatters], + (args, kwargs,)) + return pipe((method, params,), *req_formatters), ret_formatters + + def __call__(self): + raise NotImplementedError() + + +@to_tuple +def pipe_appends(fns, val): + """pipes val through a list of fns while appending the result to the + tuple output + + e.g.: + + >>> pipe_appends([lambda x: x**2, lambda x: x*10], 5) + (25, 250) + + """ + for fn in fns: + val = fn(val) + yield val + + +class BlockingMethod(BaseMethod): + def __call__(self, *args, **kwargs): + (method, params), output_formatters = self.prep_for_call(*args, **kwargs) + return pipe( + self.web3.manager.request_blocking(method, params), + *output_formatters) + + def __get__(self, obj, objType): + # allow methods to be configured for property access + if self.is_property is True: + return self.__call__() + else: + return self + + +class AsyncMethod(BaseMethod): + async def __call__(self, *args, **kwargs): + (method, params), output_formatters = self.prep_for_call(*args, **kwargs) + raw_result = await self.web3.manager.request_async(method, params) + return pipe(raw_result, *output_formatters) + + async def __get__(self, obj, objType): + if self.is_property is True: + return await self.__call__() + else: + return self diff --git a/web3/module.py b/web3/module.py index cd9b1f8d5d..b0e2ea31fe 100644 --- a/web3/module.py +++ b/web3/module.py @@ -1,8 +1,34 @@ +from web3.method import lookup_method +from web3.exceptions import UndefinedMethodError + + class Module: web3 = None + method_class = None + module_config = None + lookup_method_fn = lookup_method def __init__(self, web3): self.web3 = web3 + if self.module_config is None: + self.module_config = dict() + + def __getattr__(self, attr): + # Method lookup magic + try: + method = self.lookup_method_fn( + self.module_config, + self.method_class, + attr) + except UndefinedMethodError: + raise AttributeError + + # Emulate descriptor behavior to allow methods to control if + # they are properties or not. + if hasattr(method, '__get__'): + return method.__get__(None, self) + + return method @classmethod def attach(cls, target, module_name=None): diff --git a/web3/version.py b/web3/version.py index 6719186df5..07fed3b755 100644 --- a/web3/version.py +++ b/web3/version.py @@ -1,6 +1,46 @@ from web3.module import ( Module, ) +from web3.method import ( + AsyncMethod, + BlockingMethod, +) + + +VERSION_MODULE_CONFIG = { + 'node': { + 'name': 'node', + 'json_rpc_method': 'web3_clientVersion', + 'is_property': True + }, + 'network': { + 'name': 'network', + 'json_rpc_method': 'net_version', + 'is_property': True + }, + 'ethereum': { + 'name': 'ethereum', + 'json_rpc_method': 'eth_protocolVersion', + 'is_property': True + }, +} + + +class BaseVersion(Module): + module_config = VERSION_MODULE_CONFIG + + @property + def api(self): + from web3 import __version__ + return __version__ + + +class AsyncVersion(BaseVersion): + method_class = AsyncMethod + + +class BlockingVersion(BaseVersion): + method_class = BlockingMethod class Version(Module):