Skip to content

Commit

Permalink
feat: add ws_uri config to ape-node (#2194)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey authored Aug 2, 2024
1 parent 7af55a5 commit 5021474
Show file tree
Hide file tree
Showing 3 changed files with 193 additions and 51 deletions.
18 changes: 17 additions & 1 deletion docs/userguides/networks.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,25 @@ To configure network URIs in `node`, you can use the `ape-config.yaml` file:

```yaml
node:
# When managing or running a node, configure an IPC path globally (optional)
ipc_path: path/to/geth.ipc
ethereum:
mainnet:
uri: https://foo.node.bar
# For `uri`, you can use either HTTP, WS, or IPC values.
# **Most often, you only need HTTP!**
uri: https://foo.node.example.com
# uri: wss://bar.feed.example.com
# uri: path/to/mainnet/geth.ipc

# For strict HTTP connections, you can configure a http_uri directly.
http_uri: https://foo.node.example.com

# You can also configure a websockets URI (used by Silverback SDK).
ws_uri: wss://bar.feed.example.com

# Specify per-network IPC paths as well.
ipc_path: path/to/mainnet/geth.ipc
```
## Network Config
Expand Down
180 changes: 130 additions & 50 deletions src/ape_ethereum/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from evmchains import get_random_rpc
from pydantic.dataclasses import dataclass
from requests import HTTPError
from web3 import HTTPProvider, IPCProvider, Web3
from web3 import HTTPProvider, IPCProvider, Web3, WebsocketProvider
from web3.exceptions import ContractLogicError as Web3ContractLogicError
from web3.exceptions import (
ExtraDataLengthError,
Expand Down Expand Up @@ -177,27 +177,46 @@ def web3(self) -> Web3:

@property
def http_uri(self) -> Optional[str]:
"""
The connected HTTP URI. If using providers
like `ape-node`, configure your URI and that will
be returned here instead.
"""
try:
web3 = self.web3
except ProviderNotConnectedError:
if uri := getattr(self, "uri", None):
if _is_http_url(uri):
return uri

return None

if (
hasattr(self.web3.provider, "endpoint_uri")
and isinstance(self.web3.provider.endpoint_uri, str)
and self.web3.provider.endpoint_uri.startswith("http")
hasattr(web3.provider, "endpoint_uri")
and isinstance(web3.provider.endpoint_uri, str)
and web3.provider.endpoint_uri.startswith("http")
):
return self.web3.provider.endpoint_uri
return web3.provider.endpoint_uri

elif uri := getattr(self, "uri", None):
# NOTE: Some providers define this
return uri
if uri := getattr(self, "uri", None):
if _is_http_url(uri):
return uri

return None

@property
def ws_uri(self) -> Optional[str]:
try:
web3 = self.web3
except ProviderNotConnectedError:
return None

if (
hasattr(self.web3.provider, "endpoint_uri")
and isinstance(self.web3.provider.endpoint_uri, str)
and self.web3.provider.endpoint_uri.startswith("ws")
hasattr(web3.provider, "endpoint_uri")
and isinstance(web3.provider.endpoint_uri, str)
and web3.provider.endpoint_uri.startswith("ws")
):
return self.web3.provider.endpoint_uri
return web3.provider.endpoint_uri

return None

Expand Down Expand Up @@ -587,7 +606,7 @@ def get_receipt(
transaction_hash=txn_hash, error_message=msg_str
) from err

ecosystem_config = self.network.ecosystem_config.model_dump(by_alias=True)
ecosystem_config = self.network.ecosystem_config
network_config: dict = ecosystem_config.get(self.network.name, {})
max_retries = network_config.get("max_get_transaction_retries", DEFAULT_MAX_RETRIES_TX)
txn = {}
Expand Down Expand Up @@ -1220,9 +1239,11 @@ class EthereumNodeProvider(Web3Provider, ABC):
def uri(self) -> str:
if "url" in self.provider_settings:
raise ConfigError("Unknown provider setting 'url'. Did you mean 'uri'?")
elif "uri" in self.provider_settings:
# Use adhoc, scripted value
return self.provider_settings["uri"]
elif uri := self.provider_settings.get("uri"):
if _is_uri(uri):
return uri
else:
raise TypeError(f"Not an URI: {uri}")

config = self.config.model_dump().get(self.network.ecosystem.name, None)
if config is None:
Expand All @@ -1239,16 +1260,57 @@ def uri(self) -> str:

if "url" in network_config:
raise ConfigError("Unknown provider setting 'url'. Did you mean 'uri'?")
elif "uri" not in network_config:
if rpc := self._get_random_rpc():
return rpc
elif "http_uri" in network_config:
key = "http_uri"
elif "uri" in network_config:
key = "uri"
elif "ipc_path" in network_config:
key = "ipc_path"
elif "ws_uri" in network_config:
key = "ws_uri"
elif rpc := self._get_random_rpc():
return rpc
else:
key = "uri"

settings_uri = network_config.get("uri", DEFAULT_SETTINGS["uri"])
if _is_url(settings_uri):
settings_uri = network_config.get(key, DEFAULT_SETTINGS["uri"])
if _is_uri(settings_uri):
return settings_uri

# Likely was an IPC Path and will connect that way.
return ""
# Likely was an IPC Path (or websockets) and will connect that way.
return super().http_uri or ""

@property
def http_uri(self) -> Optional[str]:
uri = self.uri
return uri if _is_http_url(uri) else None

@property
def ws_uri(self) -> Optional[str]:
if "ws_uri" in self.provider_settings:
# Use adhoc, scripted value
return self.provider_settings["ws_uri"]

elif "uri" in self.provider_settings and _is_ws_url(self.provider_settings["uri"]):
return self.provider_settings["uri"]

config: dict = self.config.get(self.network.ecosystem.name, {})
if config == {}:
return super().ws_uri

# Use value from config file
network_config = config.get(self.network.name) or DEFAULT_SETTINGS
if "ws_uri" not in network_config:
if "uri" in network_config and _is_ws_url(network_config["uri"]):
return network_config["uri"]

return super().ws_uri

settings_uri = network_config.get("ws_uri")
if settings_uri and _is_ws_url(settings_uri):
return settings_uri

return super().ws_uri

def _get_random_rpc(self) -> Optional[str]:
if self.network.is_dev:
Expand All @@ -1273,11 +1335,26 @@ def connection_id(self) -> Optional[str]:

@property
def _clean_uri(self) -> str:
return sanitize_url(self.uri) if _is_url(self.uri) else self.uri
uri = self.uri
return sanitize_url(uri) if _is_http_url(uri) or _is_ws_url(uri) else uri

@property
def ipc_path(self) -> Path:
return self.settings.ipc_path or self.data_dir / "geth.ipc"
if ipc := self.settings.ipc_path:
return ipc

config: dict = self.config.get(self.network.ecosystem.name, {})
network_config = config.get(self.network.name, {})
if ipc := network_config.get("ipc_path"):
return Path(ipc)

# Check `uri:` config.
uri = self.uri
if _is_ipc_path(uri):
return Path(uri)

# Default (used by geth-process).
return self.data_dir / "geth.ipc"

@property
def data_dir(self) -> Path:
Expand Down Expand Up @@ -1305,7 +1382,10 @@ def _ots_api_level(self) -> Optional[int]:
def _set_web3(self):
# Clear cached version when connecting to another URI.
self._client_version = None
self._web3 = _create_web3(self.uri, ipc_path=self.ipc_path)
if uri := self.http_uri:
self._web3 = _create_web3(uri, ipc_path=self.ipc_path, ws_uri=self.ws_uri)
else:
raise ProviderError("Missing URI.")

def _complete_connect(self):
client_version = self.client_version.lower()
Expand Down Expand Up @@ -1400,25 +1480,18 @@ def connect(self):
self._complete_connect()


def _create_web3(uri: str, ipc_path: Optional[Path] = None):
# Separated into helper method for testing purposes.
def http_provider():
return HTTPProvider(uri, request_kwargs={"timeout": 30 * 60})

def ipc_provider():
# NOTE: This mypy complaint seems incorrect.
if not (path := ipc_path):
raise ValueError("IPC Path required.")

return IPCProvider(ipc_path=path)

def _create_web3(uri: str, ipc_path: Optional[Path] = None, ws_uri: Optional[str] = None):
# NOTE: This list is ordered by try-attempt.
# Try ENV, then IPC, and then HTTP last.
providers = [load_provider_from_environment]
if ipc_path:
providers.append(ipc_provider)
if uri:
providers.append(http_provider)
providers: list = [load_provider_from_environment]
if ipc := ipc_path:
providers.append(lambda: IPCProvider(ipc_path=ipc))
if http := uri:
providers.append(
lambda: HTTPProvider(endpoint_uri=http, request_kwargs={"timeout": 30 * 60})
)
if ws := ws_uri:
providers.append(lambda: WebsocketProvider(endpoint_uri=ws))

provider = AutoProvider(potential_providers=providers)
return Web3(provider)
Expand All @@ -1442,10 +1515,17 @@ def _get_default_data_dir() -> Path:
)


def _is_url(val: str) -> bool:
return (
val.startswith("https://")
or val.startswith("http://")
or val.startswith("wss://")
or val.startswith("ws://")
)
def _is_uri(val: str) -> bool:
return _is_http_url(val) or _is_ws_url(val) or _is_ipc_path(val)


def _is_http_url(val: str) -> bool:
return val.startswith("https://") or val.startswith("http://")


def _is_ws_url(val: str) -> bool:
return val.startswith("wss://") or val.startswith("ws://")


def _is_ipc_path(val: str) -> bool:
return val.endswith(".ipc")
46 changes: 46 additions & 0 deletions tests/functional/test_provider.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path
from unittest import mock

import pytest
Expand Down Expand Up @@ -493,3 +494,48 @@ def test_account_balance_state(project, eth_tester_provider, owner):
provider.connect()
bal = provider.get_balance(owner.address)
assert bal == amount


@pytest.mark.parametrize(
"uri,key",
[("ws://example.com", "ws_uri"), ("wss://example.com", "ws_uri"), ("wss://example.com", "uri")],
)
def test_node_ws_uri(project, uri, key):
node = project.network_manager.ethereum.sepolia.get_provider("node")
assert node.ws_uri is None
config = {"ethereum": {"sepolia": {key: uri}}}
with project.temp_config(node=config):
node = project.network_manager.ethereum.sepolia.get_provider("node")
assert node.ws_uri == uri

if key != "ws_uri":
assert node.uri == uri
# else: uri gets to set to random HTTP from default settings,
# but we may want to change that behavior.
# TODO: 0.9 investigate not using random if ws set.


@pytest.mark.parametrize("http_key", ("uri", "http_uri"))
def test_node_http_uri_with_ws_uri(project, http_key):
http = "http://example.com"
ws = "ws://example.com"
# Showing `uri:` as an HTTP and `ws_uri`: as an additional ws.
with project.temp_config(node={"ethereum": {"sepolia": {http_key: http, "ws_uri": ws}}}):
node = project.network_manager.ethereum.sepolia.get_provider("node")
assert node.uri == http
assert node.http_uri == http
assert node.ws_uri == ws


@pytest.mark.parametrize("key", ("uri", "ipc_path"))
def test_ipc_per_network(project, key):
ipc = "path/to/example.ipc"
with project.temp_config(node={"ethereum": {"sepolia": {key: ipc}}}):
node = project.network_manager.ethereum.sepolia.get_provider("node")
if key != "ipc_path":
assert node.uri == ipc
# else: uri gets to set to random HTTP from default settings,
# but we may want to change that behavior.
# TODO: 0.9 investigate not using random if ipc set.

assert node.ipc_path == Path(ipc)

0 comments on commit 5021474

Please sign in to comment.