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

Add support for external modules to web3 instance #2288

Merged
merged 2 commits into from Jan 5, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/providers.rst
Expand Up @@ -387,7 +387,7 @@ AsyncHTTPProvider
>>> from web3.net import AsyncNet

>>> w3 = Web3(AsyncHTTPProvider("http://127.0.0.1:8545"),
... modules={'eth': (AsyncEth,), 'net': (AsyncNet,)},
... modules={'eth': AsyncEth, 'net': AsyncNet},
... middlewares=[]) # See supported middleware section below for middleware options

Under the hood, the ``AsyncHTTPProvider`` uses the python
Expand Down
69 changes: 67 additions & 2 deletions docs/web3.main.rst
Expand Up @@ -11,7 +11,7 @@ Web3 API

.. py:class:: Web3(provider)

Each ``web3`` instance exposes the following APIs.
Each ``Web3`` instance exposes the following APIs.
fselmo marked this conversation as resolved.
Show resolved Hide resolved

Providers
~~~~~~~~~
Expand Down Expand Up @@ -388,7 +388,7 @@ Check Encodability
RPC APIS
~~~~~~~~

Each ``web3`` instance also exposes these namespaced APIs.
Each ``Web3`` instance also exposes these namespaced APIs.


.. py:attribute:: Web3.eth
Expand All @@ -410,3 +410,68 @@ Each ``web3`` instance also exposes these namespaced APIs.
.. py:attribute:: Web3.parity

See :doc:`./web3.parity`


Attaching Modules
~~~~~~~~~~~~~~~~~

Modules that inherit from the ``web3.module.Module`` class may be attached to the ``Web3`` instance either at
instantiation or by making use of the ``attach_module()`` method.

To instantiate the ``Web3`` instance with external modules:

.. code-block:: python

>>> from web3 import Web3, EthereumTesterProvider
>>> w3 = Web3(
... EthereumTesterProvider(),
... external_modules={
... # ModuleClass objects in this example inherit from the `web3.module.Module` class
... 'module1': ModuleClass1,
... 'module2': (ModuleClass2, {
... 'submodule1': ModuleClass3,
... 'submodule2': (ModuleClass4, {
... 'submodule2a': ModuleClass5, # submodule children may be nested further if necessary
... })
... })
... }
... )

# `return_zero`, in this case, is an example attribute of the `ModuleClass1` object
>>> w3.module1.return_zero
0
>>> w3.module2.submodule1.return_one
1
>>> w3.module2.submodule2.submodule2a.return_two
2


.. py:method:: w3.attach_module(module_name, module)

The ``attach_module()`` method can be used to attach an external module after the ``Web3`` instance has been
fselmo marked this conversation as resolved.
Show resolved Hide resolved
instantiated.

.. code-block:: python

>>> from web3 import Web3, EthereumTesterProvider
>>> w3 = Web3(EthereumTesterProvider())

# attaching a single module - in this case, one with the attribute `return_zero`
>>> w3.attach_module('module1', ModuleClass1)
>>> w3.module1.return_zero
0

# attaching a module with submodules
>>> w3.attach_module(
... 'module2',
... (ModuleClass2, {
... 'submodule1': ModuleClass3,
... 'submodule2': (ModuleClass4, {
... 'submodule2a': ModuleClass5,
... })
... })
... )
>>> w3.module2.submodule1.return_one
1
>>> w3.module2.submodule2.submodule2a.return_two
2
1 change: 1 addition & 0 deletions newsfragments/2288.feature.rst
@@ -0,0 +1 @@
Support for attaching external modules to the ``Web3`` instance when instantiating the ``Web3`` instance, via the ``external_modules`` argument, or via the new ``attach_module()`` method
41 changes: 41 additions & 0 deletions tests/core/conftest.py
@@ -0,0 +1,41 @@
import pytest

from web3.module import (
Module,
)


@pytest.fixture(scope='module')
def module1():
class Module1(Module):
a = 'a'

@property
def b(self):
return 'b'
return Module1


@pytest.fixture(scope='module')
def module2():
class Module2(Module):
c = 'c'

@staticmethod
def d():
return 'd'
return Module2


@pytest.fixture(scope='module')
def module3():
class Module3(Module):
e = 'e'
return Module3


@pytest.fixture(scope='module')
def module4():
class Module4(Module):
f = 'f'
return Module4
2 changes: 1 addition & 1 deletion tests/core/method-class/test_method.py
Expand Up @@ -265,7 +265,7 @@ class FakeModule(Module):
def dummy_w3():
return Web3(
EthereumTesterProvider(),
modules={'fake': (FakeModule,)},
modules={'fake': FakeModule},
middlewares=[])


Expand Down
2 changes: 1 addition & 1 deletion tests/core/method-class/test_result_formatters.py
Expand Up @@ -46,7 +46,7 @@ def dummy_w3():
w3 = Web3(
DummyProvider(),
middlewares=[result_middleware],
modules={"module": (ModuleForTest,)})
modules={"module": ModuleForTest})
return w3


Expand Down
52 changes: 46 additions & 6 deletions tests/core/utilities/test_attach_modules.py
@@ -1,5 +1,9 @@
import pytest

from eth_utils import (
is_integer,
)

from web3 import Web3
from web3._utils.module import (
attach_modules,
Expand Down Expand Up @@ -37,10 +41,10 @@ def unlock_account(self):
def test_attach_modules():
mods = {
"geth": (MockGeth, {
"personal": (MockGethPersonal,),
"admin": (MockGethAdmin,),
"personal": MockGethPersonal,
"admin": MockGethAdmin,
}),
"eth": (MockEth,),
"eth": MockEth,
}
w3 = Web3(EthereumTesterProvider(), modules={})
attach_modules(w3, mods)
Expand All @@ -51,10 +55,10 @@ def test_attach_modules():

def test_attach_modules_multiple_levels_deep():
mods = {
"eth": (MockEth,),
"eth": MockEth,
"geth": (MockGeth, {
"personal": (MockGethPersonal, {
"admin": (MockGethAdmin,),
"admin": MockGethAdmin,
}),
}),
}
Expand All @@ -76,9 +80,45 @@ def test_attach_modules_with_wrong_module_format():

def test_attach_modules_with_existing_modules():
mods = {
"eth": (MockEth,),
"eth": MockEth,
}
w3 = Web3(EthereumTesterProvider, modules=mods)
with pytest.raises(AttributeError,
match="The web3 object already has an attribute with that name"):
attach_modules(w3, mods)


def test_attach_external_modules_multiple_levels_deep(module1, module2, module3, module4):
w3 = Web3(
EthereumTesterProvider(),
external_modules={
'module1': module1,
'module2': (module2, {
'submodule1': (module3, {
'submodule2': module4,
}),
})
}
)

assert w3.isConnected()

# assert instantiated with default modules
assert hasattr(w3, 'geth')
assert hasattr(w3, 'eth')
assert is_integer(w3.eth.chain_id)

# assert instantiated with module1
assert hasattr(w3, 'module1')
assert w3.module1.a == 'a'
assert w3.module1.b == 'b'

# assert instantiated with module2 + submodules
assert hasattr(w3, 'module2')
assert w3.module2.c == 'c'
assert w3.module2.d() == 'd'

assert hasattr(w3.module2, 'submodule1')
assert w3.module2.submodule1.e == 'e'
assert hasattr(w3.module2.submodule1, 'submodule2')
assert w3.module2.submodule1.submodule2.f == 'f'
35 changes: 35 additions & 0 deletions tests/core/web3-module/test_attach_module.py
@@ -0,0 +1,35 @@
from eth_utils import (
is_integer,
)


def test_attach_module(web3, module1, module2, module3, module4):
web3.attach_module('module1', module1)
web3.attach_module(
'module2',
(module2, {
'submodule1': (module3, {
'submodule2': module4,
})
})
)

# assert module1 attached
assert hasattr(web3, 'module1')
assert web3.module1.a == 'a'
assert web3.module1.b == 'b'

# assert module2 + submodules attached
assert hasattr(web3, 'module2')
assert web3.module2.c == 'c'
assert web3.module2.d() == 'd'

assert hasattr(web3.module2, 'submodule1')
assert web3.module2.submodule1.e == 'e'
assert hasattr(web3.module2.submodule1, 'submodule2')
assert web3.module2.submodule1.submodule2.f == 'f'

# assert default modules intact
assert hasattr(web3, 'geth')
assert hasattr(web3, 'eth')
assert is_integer(web3.eth.chain_id)
19 changes: 11 additions & 8 deletions web3/_utils/module.py
Expand Up @@ -18,11 +18,13 @@

def attach_modules(
parent_module: Union["Web3", "Module"],
module_definitions: Dict[str, Sequence[Any]],
module_definitions: Dict[str, Any],
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The recursive nature of this method makes it really hard to add any useful typing here so this has to be Any unfortunately. But I am new to typing in python, maybe there's a way to make this stricter 🤔 . At least all the uses of this method are able to be a tad more strict.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I found this documentation: https://mypy.readthedocs.io/en/stable/protocols.html#recursive-protocols, but I would have to play with it some to figure out how exactly we'd use it. If digging into that seems out of the scope of this PR, I'm fine leaving this as-is.

w3: Optional[Union["Web3", "Module"]] = None
) -> None:
for module_name, module_info in module_definitions.items():
module_class = module_info[0]
module_info_is_list_like = isinstance(module_info, Sequence)
fselmo marked this conversation as resolved.
Show resolved Hide resolved

module_class = module_info[0] if module_info_is_list_like else module_info

if hasattr(parent_module, module_name):
raise AttributeError(
Expand All @@ -36,9 +38,10 @@ def attach_modules(
else:
setattr(parent_module, module_name, module_class(w3))

if len(module_info) == 2:
submodule_definitions = module_info[1]
module = getattr(parent_module, module_name)
attach_modules(module, submodule_definitions, w3)
elif len(module_info) != 1:
raise ValidationError("Module definitions can only have 1 or 2 elements.")
if module_info_is_list_like:
if len(module_info) == 2:
submodule_definitions = module_info[1]
module = getattr(parent_module, module_name)
attach_modules(module, submodule_definitions, w3)
elif len(module_info) != 1:
raise ValidationError("Module definitions can only have 1 or 2 elements.")
2 changes: 1 addition & 1 deletion web3/eth.py
Expand Up @@ -458,7 +458,7 @@ async def call(
return await self._call(transaction, block_identifier, state_override)


class Eth(BaseEth, Module):
fselmo marked this conversation as resolved.
Show resolved Hide resolved
class Eth(BaseEth):
account = Account()
defaultContractFactory: Type[Union[Contract, ConciseContract, ContractCaller]] = Contract # noqa: E704,E501
iban = Iban
Expand Down