diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index 9f0cec1895..6855cbce88 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -807,7 +807,6 @@ def empty(self) -> bool: """ ``True`` when there are no providers in the context. """ - return not self.connected_providers or not self.provider_stack def __enter__(self, *args, **kwargs): @@ -895,7 +894,7 @@ def disconnect_all(self): self.connected_providers = {} -def _set_provider(provider: "ProviderAPI") -> "ProviderAPI": +def _connect_provider(provider: "ProviderAPI") -> "ProviderAPI": connection_id = provider.connection_id if connection_id in ProviderContextManager.connected_providers: # Likely multi-chain testing or utilizing multiple on-going connections. @@ -1194,6 +1193,7 @@ def get_provider( self, provider_name: Optional[str] = None, provider_settings: Optional[dict] = None, + connect: bool = False, ): """ Get a provider for the given name. If given ``None``, returns the default provider. @@ -1203,6 +1203,7 @@ def get_provider( When ``None``, returns the default provider. provider_settings (dict, optional): Settings to apply to the provider. Defaults to ``None``. + connect (bool): Set to ``True`` when you also want the provider to connect. Returns: :class:`~ape.api.providers.ProviderAPI` @@ -1215,7 +1216,6 @@ def get_provider( f"\n {self.name}:" "\n default_provider: " ) - provider_settings = provider_settings or {} if ":" in provider_name: # NOTE: Shortcut that allows `--network ecosystem:network:http://...` to work @@ -1228,7 +1228,7 @@ def get_provider( if provider_name in self.providers: provider = self.providers[provider_name](provider_settings=provider_settings) - return _set_provider(provider) + return _connect_provider(provider) if connect else provider elif self.is_fork: # If it can fork Ethereum (and we are asking for it) assume it can fork this one. @@ -1239,7 +1239,7 @@ def get_provider( provider_settings=provider_settings, network=self, ) - return _set_provider(provider) + return _connect_provider(provider) if connect else provider raise ProviderNotFoundError( provider_name, @@ -1288,7 +1288,7 @@ def use_provider( # NOTE: The main reason we allow a provider instance here is to avoid unnecessarily # re-initializing the class. provider_obj = ( - self.get_provider(provider_name=provider, provider_settings=settings) + self.get_provider(provider_name=provider, provider_settings=settings, connect=True) if isinstance(provider, str) else provider ) @@ -1448,7 +1448,6 @@ def upstream_provider(self) -> "UpstreamProvider": When not set, will attempt to use the default provider, if one exists. """ - config_choice: str = self.config.get("upstream_provider") if provider_name := config_choice or self.upstream_network.default_provider_name: return self.upstream_network.get_provider(provider_name) diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 677942f019..94aff193be 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -35,7 +35,6 @@ ConversionError, CustomError, DecodingError, - ProviderError, SignatureError, ) from ape.logging import logger @@ -1459,7 +1458,7 @@ def decode_custom_error( try: if not (last_addr := next(trace.get_addresses_used(reverse=True), None)): return None - except ProviderError: + except Exception: # When unable to get trace-frames properly, such as eth-tester. return None diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index 932607f50d..a5989084ad 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -9,7 +9,7 @@ from copy import copy from functools import cached_property, wraps from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast import ijson # type: ignore import requests @@ -81,7 +81,8 @@ DEFAULT_PORT = 8545 DEFAULT_HOSTNAME = "localhost" -DEFAULT_SETTINGS = {"uri": f"http://{DEFAULT_HOSTNAME}:{DEFAULT_PORT}"} +DEFAULT_HTTP_URI = f"http://{DEFAULT_HOSTNAME}:{DEFAULT_PORT}" +DEFAULT_SETTINGS = {"uri": DEFAULT_HTTP_URI} def _sanitize_web3_url(msg: str) -> str: @@ -210,50 +211,217 @@ def web3(self) -> Web3: raise ProviderNotConnectedError() @property - def http_uri(self) -> Optional[str]: + def _network_config(self) -> dict: + config: dict = self.config.get(self.network.ecosystem.name, None) + if config is None: + return {} + + return (config or {}).get(self.network.name) or {} + + def _get_configured_rpc(self, key: str, validator: Callable[[str], bool]) -> Optional[str]: + # key = "uri", "http_uri", "ws_uri", or "ipc_path" + settings = self.settings # Includes self.provider_settings and top-level config. + result = None + rpc: str + if rpc := settings.get(key): + result = rpc + + else: + # See if it was configured for the network directly. + config = self._network_config + if rpc := config.get(key): + result = rpc + + if result: + if validator(result): + return result + else: + raise ConfigError(f"Invalid {key}: {result}") + + # Not configured by the user. + return None + + @property + def _configured_http_uri(self) -> Optional[str]: + return self._get_configured_rpc("http_uri", _is_http_url) + + @property + def _configured_ws_uri(self) -> Optional[str]: + return self._get_configured_rpc("ws_uri", _is_ws_url) + + @property + def _configured_ipc_path(self) -> Optional[str]: + return self._get_configured_rpc("ipc_path", _is_ipc_path) + + @property + def _configured_uri(self) -> Optional[str]: + for key in ("uri", "url"): + if rpc := self._get_configured_rpc(key, _is_uri): + return rpc + + return None + + @property + def _configured_rpc(self) -> Optional[str]: + """ + First of URI, HTTP_URI, WS_URI, IPC_PATH + found in the provider_settings or config. + """ + + # NOTE: Even though this only returns 1 value, + # each configured URI is passed in to web3 and + # will be used as each specific types of data + # is requested. + if rpc := self._configured_uri: + # The user specifically configured "uri:" + return rpc + + elif rpc := self._configured_http_uri: + # Use their configured HTTP URI. + return rpc + + elif rpc := self._configured_ws_uri: + # Use their configured WS URI. + return rpc + + elif rpc := self._configured_ipc_path: + return rpc + + return None + + def _get_connected_rpc(self, validator: Callable[[str], bool]) -> 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 + if web3 := self._web3: + if endpoint_uri := getattr(web3.provider, "endpoint_uri", None): + if isinstance(endpoint_uri, str) and validator(endpoint_uri): + return endpoint_uri - return None + return None - if ( - hasattr(web3.provider, "endpoint_uri") - and isinstance(web3.provider.endpoint_uri, str) - and web3.provider.endpoint_uri.startswith("http") - ): - return web3.provider.endpoint_uri + @property + def _connected_http_uri(self) -> Optional[str]: + return self._get_connected_rpc(_is_http_url) + + @property + def _connected_ws_uri(self) -> Optional[str]: + return self._get_connected_rpc(_is_ws_url) + + @property + def _connected_ipc_path(self) -> Optional[str]: + return self._get_connected_rpc(_is_ipc_path) + + @property + def _connected_uri(self) -> Optional[str]: + return self._get_connected_rpc(_is_uri) + + @property + def uri(self) -> str: + if rpc := self._connected_uri: + # The already connected RPC URI. + return rpc + + elif rpc := self._configured_rpc: + # Any configured rpc from settings/config. + return rpc + + elif rpc := self._default_http_uri: + # Default localhost RPC or random chain from `evmchains` + # (depending on network). + return rpc + + # NOTE: Don't use default IPC path here. IPC must be + # configured if it is the only RPC. + + raise ProviderError("Missing URI.") - if uri := getattr(self, "uri", None): - if _is_http_url(uri): + @property + def network_choice(self) -> str: + if uri := self._configured_uri: + # Ensure anything using the same choice uses the same RPC. + if self.network.name == "custom": + # Network was not really specified. Just use URI. return uri + # User is using a value like `ethereum:mainnet:` or + # configured the URI in their Ape config. + return f"{self.network.choice}:{uri}" + + return super().network_choice + + @property + def http_uri(self) -> Optional[str]: + if rpc := self._connected_http_uri: + return rpc + + elif rpc := self._configured_http_uri: + return rpc + + elif rpc := self._configured_uri: + if _is_http_url(rpc): + # "uri" found in config/settings and is WS. + return rpc + + return self._default_http_uri + + @property + def _default_http_uri(self) -> Optional[str]: + if self.network.is_dev: + # Nothing is configured and we are running geth --dev. + # Use a default localhost value. + return DEFAULT_HTTP_URI + + elif rpc := self._get_random_rpc(): + # This works when the network is in `evmchains`. + return rpc + return None @property def ws_uri(self) -> Optional[str]: - try: - web3 = self.web3 - except ProviderNotConnectedError: - return None + if rpc := self._connected_ws_uri: + return rpc - if ( - hasattr(web3.provider, "endpoint_uri") - and isinstance(web3.provider.endpoint_uri, str) - and web3.provider.endpoint_uri.startswith("ws") - ): - return web3.provider.endpoint_uri + elif rpc := self._configured_ws_uri: + # "ws_uri" found in config/settings + return rpc + + elif rpc := self._configured_uri: + if _is_ws_url(rpc): + # "uri" found in config/settings and is WS. + return rpc + + return None + + @property + def ipc_path(self) -> Optional[Path]: + if rpc := self._configured_ipc_path: + # "ipc_path" found in config/settings + return Path(rpc) + + elif rpc := self._configured_uri: + if _is_ipc_path(rpc): + # "uri" found in config/settings and is IPC. + return Path(rpc) return None + def _get_random_rpc(self) -> Optional[str]: + if self.network.is_dev: + return None + + ecosystem = self.network.ecosystem.name + network = self.network.name + + # Use public RPC if available + try: + return get_random_rpc(ecosystem, network) + except KeyError: + return None + @property def client_version(self) -> str: if not self._web3: @@ -1357,96 +1525,6 @@ class EthereumNodeProvider(Web3Provider, ABC): # NOTE: Appends user-agent to base User-Agent string. request_header: dict = {"User-Agent": f"EthereumNodeProvider/web3.py/{web3_version}"} - @property - def uri(self) -> str: - if "url" in self.provider_settings: - raise ConfigError("Unknown provider setting 'url'. Did you mean 'uri'?") - elif uri := self.provider_settings.get("uri"): - if _is_uri(uri): - return uri - else: - raise TypeError(f"Not an URI: {uri}") - - config: dict = self.config.get(self.network.ecosystem.name, None) - if config is None: - if rpc := self._get_random_rpc(): - return rpc - elif self.network.is_dev: - return DEFAULT_SETTINGS["uri"] - - # We have no way of knowing what URL the user wants. - raise ProviderError(f"Please configure a URL for '{self.network_choice}'.") - - # Use value from config file - network_config: dict = (config or {}).get(self.network.name) or DEFAULT_SETTINGS - if "url" in network_config: - raise ConfigError("Unknown provider setting 'url'. Did you mean 'uri'?") - 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(key, DEFAULT_SETTINGS["uri"]) - if _is_uri(settings_uri): - # Is true if HTTP, WS, or IPC. - return settings_uri - - # Is not HTTP, WS, or IPC. Raise an error. - raise ConfigError(f"Invalid URI (not HTTP, WS, or IPC): {settings_uri}") - - @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: - return None - - ecosystem = self.network.ecosystem.name - network = self.network.name - - # Use public RPC if available - try: - return get_random_rpc(ecosystem, network) - except KeyError: - return None - @property def connection_str(self) -> str: return self.uri or f"{self.ipc_path}" @@ -1460,24 +1538,6 @@ def _clean_uri(self) -> str: 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: - 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: if self.settings.data_dir: @@ -1485,6 +1545,14 @@ def data_dir(self) -> Path: return _get_default_data_dir() + @property + def ipc_path(self) -> Path: + if path := super().ipc_path: + return path + + # Default (used by geth-process). + return self.data_dir / "geth.ipc" + @cached_property def _ots_api_level(self) -> Optional[int]: # NOTE: Returns None when OTS namespace is not enabled. @@ -1548,6 +1616,7 @@ def _complete_connect(self): # NOTE: We have to check both earliest and latest # because if the chain was _ever_ PoA, we need # this middleware. + is_likely_poa = False for option in ("earliest", "latest"): try: block = self.web3.eth.get_block(option) # type: ignore[arg-type] @@ -1682,8 +1751,8 @@ 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") +def _is_ipc_path(val: Union[str, Path]) -> bool: + return f"{val}".endswith(".ipc") class _LazyCallTrace(ManagerAccessMixin): diff --git a/src/ape_node/provider.py b/src/ape_node/provider.py index c14bc6a81a..f8af5eeb08 100644 --- a/src/ape_node/provider.py +++ b/src/ape_node/provider.py @@ -6,7 +6,6 @@ from urllib.parse import urlparse from eth_utils import add_0x_prefix, to_hex -from evmchains import get_random_rpc from geth.chain import initialize_chain from geth.process import BaseGethProcess from geth.wrapper import construct_test_chain_kwargs @@ -292,9 +291,9 @@ def wait(self, *args, **kwargs): class EthereumNetworkConfig(PluginConfig): # Make sure you are running the right networks when you try for these - mainnet: dict = {"uri": get_random_rpc("ethereum", "mainnet")} - holesky: dict = {"uri": get_random_rpc("ethereum", "holesky")} - sepolia: dict = {"uri": get_random_rpc("ethereum", "sepolia")} + mainnet: dict = {} + holesky: dict = {} + sepolia: dict = {} # Make sure to run via `geth --dev` (or similar) local: dict = {**DEFAULT_SETTINGS.copy(), "chain_id": DEFAULT_TEST_CHAIN_ID} diff --git a/src/ape_test/provider.py b/src/ape_test/provider.py index cb91eacaa7..9814daa0b5 100644 --- a/src/ape_test/provider.py +++ b/src/ape_test/provider.py @@ -2,6 +2,7 @@ from ast import literal_eval from collections.abc import Iterator from functools import cached_property +from pathlib import Path from re import Pattern from typing import TYPE_CHECKING, Any, Optional, cast @@ -182,6 +183,18 @@ def auto_mine(self, value: Any) -> None: def max_gas(self) -> int: return self.evm_backend.get_block_by_number("latest")["gas_limit"] + @property + def http_uri(self) -> Optional[str]: + return None + + @property + def ws_uri(self) -> Optional[str]: + return None + + @property + def ipc_path(self) -> Optional[Path]: + return None + def connect(self): self.__dict__.pop("tester", None) self._web3 = Web3(self.tester) diff --git a/tests/functional/geth/test_network_manager.py b/tests/functional/geth/test_network_manager.py index a0949ee245..26fe5ed09b 100644 --- a/tests/functional/geth/test_network_manager.py +++ b/tests/functional/geth/test_network_manager.py @@ -53,3 +53,14 @@ def test_parse_network_choice_evmchains(networks, connection_str): with networks.parse_network_choice(connection_str) as moon_provider: assert moon_provider.network.name == "moonriver" assert moon_provider.network.ecosystem.name == "moonbeam" + + # When including the HTTP URL in the network choice, + # `provider.network_choice` should also include it. + # This ensures when relying on `provider.network_choice` for + # multiple connections that they all use the same HTTP URI. + expected_network_choice = ( + f"moonbeam:moonriver:{connection_str}" + if "http" in connection_str + else f"{connection_str}:node" + ) + assert moon_provider.network_choice == expected_network_choice diff --git a/tests/functional/geth/test_provider.py b/tests/functional/geth/test_provider.py index a6f40c2478..2af47b08ab 100644 --- a/tests/functional/geth/test_provider.py +++ b/tests/functional/geth/test_provider.py @@ -6,7 +6,6 @@ from eth_pydantic_types import HashBytes32 from eth_typing import HexStr from eth_utils import keccak, to_hex -from evmchains import PUBLIC_CHAIN_META from hexbytes import HexBytes from web3 import AutoProvider, Web3 from web3.exceptions import ContractLogicError as Web3ContractLogicError @@ -72,14 +71,6 @@ def test_uri(geth_provider): assert geth_provider.uri == GETH_URI -@geth_process_test -def test_uri_localhost_not_running_uses_random_default(config): - cfg = config.get_config("node").ethereum.mainnet - assert cfg["uri"] in PUBLIC_CHAIN_META["ethereum"]["mainnet"]["rpc"] - cfg = config.get_config("node").ethereum.sepolia - assert cfg["uri"] in PUBLIC_CHAIN_META["ethereum"]["sepolia"]["rpc"] - - @geth_process_test def test_uri_when_configured(geth_provider, project, ethereum): settings = geth_provider.provider_settings @@ -144,7 +135,7 @@ def test_uri_invalid(geth_provider, project, ethereum): try: with project.temp_config(**config): # Assert we use the config value. - expected = rf"Invalid URI \(not HTTP, WS, or IPC\): {re.escape(value)}" + expected = rf"Invalid uri: {re.escape(value)}" with pytest.raises(ConfigError, match=expected): _ = geth_provider.uri @@ -632,7 +623,7 @@ def test_send_call_skip_trace(mocker, geth_provider, ethereum, tx_for_call): @geth_process_test def test_network_choice(geth_provider): actual = geth_provider.network_choice - expected = "ethereum:local:node" + expected = "ethereum:local:http://127.0.0.1:5550" assert actual == expected @@ -845,12 +836,16 @@ def test_start(process_factory_patch, convert, project, geth_provider): def test_start_from_ws_uri(process_factory_patch, project, geth_provider, key): uri = "ws://localhost:5677" + settings = geth_provider.provider_settings with project.temp_config(node={"ethereum": {"local": {key: uri}}}): + geth_provider.provider_settings = {} try: geth_provider.start() except Exception: pass # Exceptions are fine here. + geth_provider.provider_settings = settings + actual = process_factory_patch.call_args[0][0] # First "arg" assert actual == uri diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py index 7d1087c98b..f8d8adc6a0 100644 --- a/tests/functional/test_cli.py +++ b/tests/functional/test_cli.py @@ -803,7 +803,7 @@ def test_connected_provider_command_with_network_option_and_cls_types_false(runn @network_option() def cmd(network): assert isinstance(network, str) - assert network == "ethereum:local:node" + assert network.startswith("ethereum:local") # NOTE: Must use a network that is not the default. spec = ("--network", "ethereum:local:node") diff --git a/tests/functional/test_network_manager.py b/tests/functional/test_network_manager.py index c90d01ad29..c947e0f7a3 100644 --- a/tests/functional/test_network_manager.py +++ b/tests/functional/test_network_manager.py @@ -301,10 +301,7 @@ def test_create_custom_provider_ws(networks, scheme): def test_create_custom_provider_ipc(networks): provider = networks.create_custom_provider("path/to/geth.ipc") assert provider.ipc_path == Path("path/to/geth.ipc") - - # The IPC path should not be in URI field, different parts - # of codebase may expect an actual URI. - assert provider.uri != provider.ipc_path + assert provider.uri == provider.ipc_path def test_ecosystems(networks):