Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

outline for async web3 api: implement async Version module #1166

Merged
merged 4 commits into from
Jan 17, 2019

Conversation

dylanjw
Copy link
Contributor

@dylanjw dylanjw commented Dec 15, 2018

Related to Issue #1161.

Minimal web3 async api. Includes:

  • AsyncEthereumTesterProvider was added, which has eth-tester related middlewares removed, with a request function that doesnt await.
  • request_async was updated in RequestManager to be a coroutine. code copy of the blocking request. Im preferring simple over dry for this push.
  • Added a framework for new web3 module apis which comprises of:
    • AsyncMethod and BlockingMethod callable classes (can be configured for property access)
    • Rough method configuration format (json for now).
    • web3.Module manages the "attaching" of method callables to the module, via a method_config and method_class. EDIT: methods are attached via a descriptor now.
    • Demo of the changes using web3.version.Version module.
    • Not built-in to the web3 object. e.g.:
from web3 import Web3
from web3.providers.tester.main import AsyncEthereumTesterProvider
from web3.version import AsyncVersion

w3 = Web3(
    AsyncEthereumTesterProvider, 
    middlewares=[], 
    modules={"async_version": AsyncVersion})

Cute Animal Picture

image

web3/method.py Outdated
'name': "signInBlood",
'mungers': [],
'json_rpc_method': "eth_signTransactionInBlood",
}
Copy link
Contributor Author

@dylanjw dylanjw Dec 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meant to move this and the test below.

For those interested in what 'mungers' are (thanks for the fun word @carver). It is the list of the functions that the python method arguments get piped through, that should output a json_rpc ready param dict. Havent found a name I like better. Because these functions can be validators, normalizers, formatters, occur in any order, with probably a huge variation arity, etc., "munger" seems appropriate. These are being kept separate from the request parameter and result formatters, taken from the middleware formatters.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm good with "mungers" unless something better surfaces. Maybe good to try and find a good place to write up a nice comment documenting the validator/normalizer/formatter concepts it encapsulates

"""
for fn in fns:
val = fn(val)
yield val
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to utils

web3/module.py Outdated
self.method_class,
attr)
except UndefinedMethodError:
raise AttributeError
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im wondering if it is worth caching the method object after the first lookup.

web3/manager.py Outdated
async def _make_async_request(self, method, params):
"""TODO: Can this be made dry? Going for dumb solution for now.
"""
for provider in self.providers:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to drop this loop in v5 by removing the multi-provider API. cc @kclowes

web3/manager.py Outdated
@@ -102,6 +98,26 @@ def _make_request(self, method, params):
)
)

async def _make_async_request(self, method, params):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming convention for this type of thing has been to use a coro_ prefix so maybe _coro_make_request.

web3/method.py Outdated
'name': "signInBlood",
'mungers': [],
'json_rpc_method': "eth_signTransactionInBlood",
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm good with "mungers" unless something better surfaces. Maybe good to try and find a good place to write up a nice comment documenting the validator/normalizer/formatter concepts it encapsulates

web3/method.py Outdated
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about removing this and only implementing the concept of direct property lookups at the Module level?

class VersionModule:
    get_version = VersionMethod(...)

    @property
    def version(self):
        return self.get_version()

Would work similarly for async

web3/method.py Outdated


@to_tuple
def pipe_appends(fns, val):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, it's kind of like https://toolz.readthedocs.io/en/latest/api.html#toolz.itertoolz.accumulate combined with pipe. Maybe re-use the name and call this accumulate_pipe or pipe_and_accumulate?

web3/module.py Outdated
if self.module_config is None:
self.module_config = dict()

def __getattr__(self, attr):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm 👎 on using __getattr__. It makes introspection and type hinting not work well.

I'd advocate for something like:

class Module:
    get_version: GetVersionMethod  # this is how we get strong-ish type hints.

    def __init__(self, ...):
        self.get_version = GetVersion(...)

In theory we can do this by assigning them at the class level and using the descriptor API to get access to the w3 instance.

@dylanjw
Copy link
Contributor Author

dylanjw commented Dec 19, 2018

I want to restate the goals for this pull request now that it has been worked through further. It is basically restating the opening description, but with more clarity on my part.

This PRs primary intention is to implement the Method objects and the strategy for sync and async execution. More specifically it aims to determine the association between the modules, method definitions and async and sync calling functions.

The following is a description of the structure ive chosen:

The Method class is in charge of interpreting a method configuration. Meaning the Method is concerned with collecting the method lookup, mungers, formatters and executing them on the inputs.

In this first version the method configuration is a dict, but I see these becoming a small class that can validate and document the configuration requirements.

The Method class has a secondary concern with directing the calls to the method to a delegated call function. The Method class defines __get__ method, becoming a descriptor. This overrides attribute access from the module with __get__ which is passed the object or the object class. In this way the module is able to define the caller function delegate, rather than in the Method object. This saves having to instantiate the Method objects for both async and sync modules.

The method does not retain any state from the module object. The __get__ method just passes itself (the Method object) and the module web3 instance to the call function delegate. The method object provides the input processing and the web3 instance provides the manager request function.

Using this structure the Method objects can be defined once in a shared base class. To make this safe, I need to find a way to protect the Method class from mutations after its instantiation with the method config.

Items left todo:

  • Make the configured method objects immutable.
  • Cleanup tests for configuring the Method class and input processing. Make sure I have the following:

Tests for the following:

Method.method_selector_fn()

  1. Accepts string and callable config.
  2. raises exception for anything else.

Method.get_formatters()

  1. Any falsy formatter_lookup_fn config gets the default formatters.
  2. Any non-falsy formatter looup function returns expected formatters.

Method.input_munger():

  1. Passing parameters to the method get passed to mungers expecting the
    same parameters.
  2. A falsy mungers config results in use of the default mungers.

Method.process_params():
process_params orchestrates the mungers, method_lookup and formatters so there
should be tests for the following cases:

  1. No mungers, no lookup config, no formatters -> exception
  2. No mungers, a string lookup config, no formatters.
    a. with params --> exception
    b. no params --> method str, no request params
  3. No mungers, a method lookup fn, no formatters -> method matches param
  4. Several mungers defined, a method str, no formatters -> expected req params
  5. no mungers, a method str, several formatters defined
    a. vary the method str -> correct formatter selected
    b. check expected output
  6. A comprehensive test.

@dylanjw
Copy link
Contributor Author

dylanjw commented Dec 20, 2018

@pipermerriam @kclowes @carver This is ready for review.

For the next PR I plan on working on another module that requires more input processing and formatters taken from middlewares.

@carver
Copy link
Collaborator

carver commented Dec 22, 2018

Sorry, with the holidays, it will take a while before it is reviewed. Maybe early January (for me at least).

@dylanjw
Copy link
Contributor Author

dylanjw commented Dec 22, 2018

NP. In the meantime I will start on another PR to work out how migrating the middleware formatters will look, and backmerge any changes from this PR.

web3/method.py Outdated
formatting. Any processing on the input parameters that need to happen before
json_rpc method string selection occurs.

A note about mungers: The first munger should reflect the desired
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This note would be a lot clearer were it to contain a code example.

web3/method.py Outdated
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 length tuple of lists containing the
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick

My vocabulary for this would be 2 length tuple -> 2-tuple but I'm not sure if that nomenclature is well known.

web3/method.py Outdated
return obj.retrieve_caller_fn(self)

def __setattr__(self, key, val):
raise TypeError('Method cannot be modified after instantiation')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaning towards 👎 on this. trying to enforce immutability on objects seems like a deep rabbit hole.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as it's not trying to make a security guarantee, it's simply nudging users toward ideal behavior, then I'm more open to this kind of thing.

web3/method.py Outdated
"""
_config = None

def __init__(self, method_config, web3=None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious at the use of a dictionary instead of just having individual parameters for each of the various parts that a Method needs. I'm preferential towards the later.

web3/method.py Outdated
if i == 0:
vals = munger(*args, **kwargs)
else:
vals = munger(*vals)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can split this up to be a bit cleaner.

mungers_iter = iter(mungers)
root_munger = first(mungers_iter)
munged_inputs = pipe(root_munger(*args, **kwargs), *mungers)

Code above doesn't take into account that each munger needs to be called as munger(*args). One way to deal with this would be to just adjust the API so that each munger is called as munger(args) or maybe a small helper which did the *args application that you wrap each munger function in.

def star_apply(fn):
    @functools.wraps(fn)
    def inner(args):
        return fn(*args)
    return inner

which conversts the above code to:

mungers_iter = iter(mungers)
root_munger = first(mungers_iter)
munged_inputs = pipe(root_munger(*args, **kwargs), *map(star_apply, mungers))

web3/method.py Outdated


@to_tuple
def pipe_and_accumulate(val, fns):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially add an _ prefix to denote private API. (or move it to live under web3._utils

super().__setattr__('_config', dict(method_config))

def __get__(self, obj=None, objType=None):
return obj.retrieve_caller_fn(self)

This comment was marked as resolved.

web3/method.py Outdated
def __init__(self, method_config, web3=None):
super().__setattr__('_config', dict(method_config))

def __get__(self, obj=None, objType=None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you convert to klass or obj_type or typ to stick with python naming conventions.

@dylanjw dylanjw force-pushed the async_api branch 4 times, most recently from 2815866 to 8ca6573 Compare January 13, 2019 08:34
@dylanjw
Copy link
Contributor Author

dylanjw commented Jan 14, 2019

Ive added changes to give the mungers access to the module instance, to support certain features, like the default account api.

I didnt want to expose the entire web3 instance to the mungers, so I I created a new Module class to eventually replace it (ModuleV2) that isolates the web3 instance to the method calling functions, rather than exposing a web3 instance to the module. The method callers pass the module instance to the method input processing.

Organizing it this way makes it hard to write "re-entrant" calls at the module level. If I come across any web3 calls in the modules as Im working through the conversion, they will be moved to a middleware, where they can be written for both blocking and async execution pathways.

web3/method.py Outdated
def inner(args):
return fn(*args)
def inner(module_args):
module, args = module_args
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current API of having all the mungers return the module and then pass it onto the next seems problematic/error prone. Instead of requiring the munger to both be accepted as an argument as well I think we should do the following.

  1. only pass as argument.
  2. use curry or functools.partial to set the module parameter on all the munger functions before passing the values through.

This would make the map(star_apply, mungers) into something like:

map(lambda munger: star_apply(functools.partial(munger, module)), mungers)

web3/method.py Outdated
if not args and not kwargs:
return list()
return module, list()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the list() intentional here or should this be converted to tuple

.localvimrc Outdated
@@ -0,0 +1,3 @@
let test#python#pytest#options = {
\ 'suite': ' tests/core',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it leaked in and should be removed.

@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?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this shouldn't be too big a deal, though I might advocate for us ensuring that every one of our convenience properties has a method version as well.

class Version(Module):
    @property
    def api(self):
        return self.get_version()

    def get_version(self):
        ...

Reason being that while awaitable properties work just fine, they are not a very intuitive API where-as their method equivalents don't suffer from this awkwardness. It will be up to the user to view the documentation for the various methods and to know that they are coroutines.

_get_protocol_version = Method('eth_protocolVersion')

@property
def api(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm 👍 on having a mixed-bag of coroutine and normal properties. Forcing simple APIs like this that don't require I/O to be coroutines makes them more complex than necessary and adds un-necessary performance overhead. 👍

@dylanjw dylanjw force-pushed the async_api branch 2 times, most recently from 8f264c1 to 486e28f Compare January 15, 2019 07:36
self.web3,
tuple(self.middleware_stack))
self.logger.debug("Making request. Method: %s", method)
return request_func(method, params)
Copy link
Contributor Author

@dylanjw dylanjw Jan 15, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kclowes @njgheorghita
Before I merge this, it would be good to get a couple sets of eyes on this. Its the only place in this PR that touches the critical api.

With no longer iterating over the provider list, I think the UnhandledRequest exception is unnecessary and we can let the CannotHandleRequest pass through. Or maybe this should be catching the "CannotHandleRequest" error like before?

For reference here is the request function when it had the provider list so you dont have to dig through the history:

    #   
    # Provider requests and response
    #   
    def _make_request(self, method, params):
        for provider in self.providers:
            request_func = provider.request_func(self.web3, tuple(self.middleware_stack))
            self.logger.debug("Making request. Method: %s", method)
            try:
                return request_func(method, params)
            except CannotHandleRequest:
                continue
        else:
            raise UnhandledRequest(
                "No providers responded to the RPC request:\n"
                "method:{0}\n"
                "params:{1}\n".format(
                    method,
                    params,
                )   
            )   

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I understand, it looks good to me. I'm thinking it's fine to let the CannotHandleRequest exception pass through from the provider rather than catching it here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though, maybe beefing up the msg here to have more info like in the UnhandledRequest msg from before could be useful

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks correct.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If CannotHandleRequest is no longer used anywhere it can be removed (didn't look to see if you already did this)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree with @njgheorghita. CannotHandleRequest makes sense to me, especially with a better error message!

- Allows method configuration to be made in one place.
- Blocking and Async execution pathways can be controlled
by the module, using the is_async attribute.
@dylanjw dylanjw merged commit e2763c5 into ethereum:master Jan 17, 2019
@kclowes kclowes added this to the Async Web3 API milestone Aug 7, 2019
@adiochen1
Copy link

Hey, does web3 currently support async? How do I initiate HTTPProvider Web3 client in an async-matter?

@kclowes
Copy link
Collaborator

kclowes commented Jan 6, 2021

@adichen3798 It does not yet support async, but the goal is to get something out by the end of the quarter!

@Nighty13
Copy link

Hello , still no support for async?

@cosmikwolf
Copy link

looks like async web3 is in development here: https://github.com/guanqun/async-web3.py

@kclowes
Copy link
Collaborator

kclowes commented May 27, 2021

Nice! Async is a work in progress for us but is actively being worked on. This issue is the most up to date if you're interested in following along.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants