diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9bbcebef7..bc69f6b5fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: - id: isort - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 23.11.0 hooks: - id: black name: black @@ -21,7 +21,7 @@ repos: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 + rev: v1.7.0 hooks: - id: mypy additional_dependencies: [ diff --git a/setup.py b/setup.py index 4c54c5f549..cb9e412881 100644 --- a/setup.py +++ b/setup.py @@ -18,8 +18,8 @@ "hypothesis-jsonschema==0.19.0", # JSON Schema fuzzer extension ], "lint": [ - "black>=23.10.1,<24", # Auto-formatter and linter - "mypy>=1.6.1,<2", # Static type analyzer + "black>=23.11.0,<24", # Auto-formatter and linter + "mypy>=1.7.0,<2", # Static type analyzer "types-PyYAML", # Needed due to mypy typeshed "types-requests", # Needed due to mypy typeshed "types-setuptools", # Needed due to mypy typeshed @@ -124,7 +124,7 @@ "web3[tester]>=6.7.0,<7", # ** Dependencies maintained by ApeWorX ** "eip712>=0.2.1,<0.3", - "ethpm-types>=0.5.8,<0.6", + "ethpm-types>=0.5.10,<0.6", "evm-trace>=0.1.0a23", ], entry_points={ diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index c37386ffd4..1c37a73413 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -4,6 +4,7 @@ from typing import ( TYPE_CHECKING, Any, + ClassVar, Collection, Dict, Iterator, @@ -577,19 +578,45 @@ class ProviderContextManager(ManagerAccessMixin): provider_stack: List[str] = [] disconnect_map: Dict[str, bool] = {} - def __init__(self, provider: "ProviderAPI", disconnect_after: bool = False): + # We store a provider object at the class level for use when disconnecting + # due to an exception, when interactive mode is set. If we don't hold on + # to a reference to this object, the provider is dropped and reconnecting results + # in losing state when using a spawned local provider + _recycled_provider: ClassVar[Optional["ProviderAPI"]] = None + + def __init__( + self, + provider: "ProviderAPI", + disconnect_after: bool = False, + disconnect_on_exit: bool = True, + ): self._provider = provider self._disconnect_after = disconnect_after + self._disconnect_on_exit = disconnect_on_exit + self._skipped_disconnect = False @property def empty(self) -> bool: return not self.connected_providers or not self.provider_stack def __enter__(self, *args, **kwargs): + # If we have a recycled provider available, this means our last exit + # was due to an exception during interactive mode. We should resume that + # same connection, but also clear the object so we don't do this again + # in later provider contexts, which we would want to behave normally + if self._recycled_provider is not None: + # set inner var to the recycled provider for use in push_provider() + self._provider = self._recycled_provider + ProviderContextManager._recycled_provider = None return self.push_provider() - def __exit__(self, *args, **kwargs): - self.pop_provider() + def __exit__(self, exception, *args, **kwargs): + if not self._disconnect_on_exit and exception is not None: + # We want to skip disconnection when exiting due to an exception in interactive mode + if provider := self.network_manager.active_provider: + ProviderContextManager._recycled_provider = provider + else: + self.pop_provider() def push_provider(self): must_connect = not self._provider.is_connected diff --git a/src/ape/api/projects.py b/src/ape/api/projects.py index 2e4921654e..92283a3e96 100644 --- a/src/ape/api/projects.py +++ b/src/ape/api/projects.py @@ -183,7 +183,7 @@ def _create_manifest( compiler_data: Optional[List[Compiler]] = None, ) -> PackageManifest: manifest = initial_manifest or PackageManifest() - manifest.name = PackageName(name.lower()) if name is not None else manifest.name + manifest.name = PackageName(__root__=name.lower()) if name is not None else manifest.name manifest.version = version or manifest.version manifest.sources = cls._create_source_dict(source_paths, contracts_path) manifest.contract_types = contract_types diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index cb7fe5bfac..b510537cb7 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -1546,14 +1546,15 @@ def _create_call_tree_node( # Use raw value since it is not a real address. contract_id = address.hex() - call_type = evm_call.call_type.value + call_type_str: str = getattr(evm_call.call_type, "value", f"{evm_call.call_type}") + input_data = evm_call.calldata if "CREATE" in call_type_str else evm_call.calldata[4:].hex() return CallTreeNode( calls=[self._create_call_tree_node(x, txn_hash=txn_hash) for x in evm_call.calls], - call_type=call_type, + call_type=call_type_str, contract_id=contract_id, failed=evm_call.failed, gas_cost=evm_call.gas_cost, - inputs=evm_call.calldata if "CREATE" in call_type else evm_call.calldata[4:].hex(), + inputs=input_data, method_id=evm_call.calldata[:4].hex(), outputs=evm_call.returndata.hex(), raw=evm_call.dict(), diff --git a/src/ape/cli/commands.py b/src/ape/cli/commands.py index 8746b66294..13a3abbf6c 100644 --- a/src/ape/cli/commands.py +++ b/src/ape/cli/commands.py @@ -6,6 +6,16 @@ from ape import networks +def check_parents_for_interactive(ctx: Context) -> bool: + interactive: bool = ctx.params.get("interactive", False) + if interactive: + return True + # If not found, check the parent context. + if interactive is None and ctx.parent: + return check_parents_for_interactive(ctx.parent) + return False + + class NetworkBoundCommand(click.Command): """ A command that uses the :meth:`~ape.cli.options.network_option`. @@ -14,5 +24,6 @@ class NetworkBoundCommand(click.Command): def invoke(self, ctx: Context) -> Any: value = ctx.params.get("network") or networks.default_ecosystem.name - with networks.parse_network_choice(value): + interactive = check_parents_for_interactive(ctx) + with networks.parse_network_choice(value, disconnect_on_exit=not interactive): super().invoke(ctx) diff --git a/src/ape/managers/chain.py b/src/ape/managers/chain.py index 42fcaad0d2..5114bd2c67 100644 --- a/src/ape/managers/chain.py +++ b/src/ape/managers/chain.py @@ -287,9 +287,10 @@ def poll_blocks( # Get number of last block with the necessary amount of confirmations. block = None - if start_block is not None: + head_minus_confirms = self.height - required_confirmations + if start_block is not None and start_block <= head_minus_confirms: # Front-load historical blocks. - for block in self.range(start_block, self.height - required_confirmations + 1): + for block in self.range(start_block, head_minus_confirms + 1): yield block if block: diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index 0458822447..6f3609b6d7 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -418,6 +418,7 @@ def parse_network_choice( network_choice: Optional[str] = None, provider_settings: Optional[Dict] = None, disconnect_after: bool = False, + disconnect_on_exit: bool = True, ) -> ProviderContextManager: """ Parse a network choice into a context manager for managing a temporary @@ -445,7 +446,11 @@ def parse_network_choice( provider = self.get_provider_from_choice( network_choice=network_choice, provider_settings=provider_settings ) - return ProviderContextManager(provider=provider, disconnect_after=disconnect_after) + return ProviderContextManager( + provider=provider, + disconnect_after=disconnect_after, + disconnect_on_exit=disconnect_on_exit, + ) @property def default_ecosystem(self) -> EcosystemAPI: diff --git a/src/ape/managers/project/manager.py b/src/ape/managers/project/manager.py index e79a0e72ea..6a744c4e51 100644 --- a/src/ape/managers/project/manager.py +++ b/src/ape/managers/project/manager.py @@ -275,12 +275,12 @@ def extract_manifest(self) -> PackageManifest: return manifest def _extract_manifest_dependencies(self) -> Optional[Dict[PackageName, AnyUrl]]: - package_dependencies: Dict[PackageName, AnyUrl] = {} + package_dependencies: Dict[str, AnyUrl] = {} for dependency_config in self.config_manager.dependencies: package_name = dependency_config.name.replace("_", "-").lower() - package_dependencies[PackageName(package_name)] = dependency_config.uri + package_dependencies[package_name] = dependency_config.uri - return package_dependencies + return cast(Optional[Dict[PackageName, AnyUrl]], package_dependencies) @property def _package_deployments_folder(self) -> Path: diff --git a/src/ape/types/coverage.py b/src/ape/types/coverage.py index 462b6ca1b1..77a01f6d4e 100644 --- a/src/ape/types/coverage.py +++ b/src/ape/types/coverage.py @@ -7,11 +7,11 @@ from xml.etree.ElementTree import Element, SubElement, tostring import requests -from ethpm_types import BaseModel from ethpm_types.source import ContractSource, SourceLocation from ape._pydantic_compat import NonNegativeInt, validator from ape.logging import logger +from ape.utils.basemodel import BaseModel from ape.utils.misc import get_current_timestamp_ms from ape.version import version as ape_version diff --git a/src/ape/utils/basemodel.py b/src/ape/utils/basemodel.py index 593125b427..f2e3de61c5 100644 --- a/src/ape/utils/basemodel.py +++ b/src/ape/utils/basemodel.py @@ -203,7 +203,7 @@ def __getattr__(self, name: str) -> Any: """ try: - return super().__getattr__(name) + return super().__getattribute__(name) except AttributeError: extras_checked = set() for ape_extra in self.__ape_extra_attributes__(): @@ -281,6 +281,7 @@ class Config: arbitrary_types_allowed = True underscore_attrs_are_private = True copy_on_model_validation = "none" + use_enum_values = False def __dir__(self) -> List[str]: """ diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index eb87d9064f..07c70c2250 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -138,9 +138,7 @@ class ForkedNetworkConfig(NetworkConfig): """ -def _create_local_config( - default_provider: Optional[str] = None, use_fork: bool = False, **kwargs -) -> NetworkConfig: +def _create_local_config(default_provider: Optional[str] = None, use_fork: bool = False, **kwargs): return _create_config( base_fee_multiplier=1.0, default_provider=default_provider, @@ -155,7 +153,7 @@ def _create_local_config( def _create_config( required_confirmations: int = 2, base_fee_multiplier: float = DEFAULT_LIVE_NETWORK_BASE_FEE_MULTIPLIER, - cls: Type[NetworkConfig] = NetworkConfig, + cls: Type = NetworkConfig, **kwargs, ) -> NetworkConfig: return cls( diff --git a/src/ape_geth/provider.py b/src/ape_geth/provider.py index 27dde32017..2dfe42bde5 100644 --- a/src/ape_geth/provider.py +++ b/src/ape_geth/provider.py @@ -275,6 +275,10 @@ def uri(self) -> str: network_config = config.get(self.network.name) or DEFAULT_SETTINGS return network_config.get("uri", DEFAULT_SETTINGS["uri"]) + @property + def connection_id(self) -> Optional[str]: + return f"{self.network_choice}:{self.uri}" + @property def _clean_uri(self) -> str: return sanitize_url(self.uri) diff --git a/src/ape_plugins/utils.py b/src/ape_plugins/utils.py index 9f19d64094..7ec88afefe 100644 --- a/src/ape_plugins/utils.py +++ b/src/ape_plugins/utils.py @@ -5,7 +5,7 @@ from typing import Iterator, List, Optional, Sequence, Set, Tuple from ape.__modules__ import __modules__ -from ape._pydantic_compat import root_validator +from ape._pydantic_compat import root_validator, validator from ape.logging import logger from ape.plugins import clean_plugin_name from ape.utils import BaseInterfaceModel, get_package_version, github_client @@ -373,14 +373,27 @@ def __bool__(self) -> bool: return len(self.plugins) > 0 def __repr__(self) -> str: - return f"<{self.name} Plugins Group>" + try: + return f"<{self.name} Plugins Group>" + except Exception: + # Prevent exceptions happening in repr() + logger.log_debug_stack_trace() + return "" def __str__(self) -> str: return self.to_str() + @validator("plugin_type") + def validate_plugin_type(cls, value): + return PluginType(value) if isinstance(value, str) else value + + @property + def plugin_type_str(self) -> str: + return getattr(self.plugin_type, "value", str(self.plugin_type)) + @property def name(self) -> str: - return self.plugin_type.value.capitalize() + return self.plugin_type_str.capitalize() @property def plugin_names(self) -> List[str]: @@ -426,8 +439,13 @@ def __init__( self.metadata = metadata def __repr__(self) -> str: - to_display_str = ", ".join([x.value for x in self.include]) - return f"" + try: + to_display_str = ", ".join([x.value for x in self.include]) + return f"" + except Exception: + # Prevent exceptions happening in repr() + logger.log_debug_stack_trace() + return "" def __str__(self) -> str: sections = [] @@ -441,6 +459,9 @@ def __str__(self) -> str: if PluginType.AVAILABLE in self.include and self.metadata.available: sections.append(self.metadata.available) + if not sections: + return "" + # Use a single max length for all the sections. max_length = max(x.max_name_length for x in sections) diff --git a/src/ape_run/_cli.py b/src/ape_run/_cli.py index 737b6f9d8f..e9f180ce94 100644 --- a/src/ape_run/_cli.py +++ b/src/ape_run/_cli.py @@ -10,7 +10,7 @@ import click from click import Command, Context, Option -from ape import project +from ape import networks, project from ape.cli import NetworkBoundCommand, network_option, verbosity_option from ape.cli.options import _VERBOSITY_VALUES, _create_verbosity_kwargs from ape.exceptions import ApeException, handle_ape_exception @@ -76,13 +76,15 @@ def invoke(self, ctx: Context) -> Any: if ctx.params["interactive"]: # Print the exception trace and then launch the console # Attempt to use source-traceback style printing. - if not isinstance(err, ApeException) or not handle_ape_exception( - err, [ctx.obj.project_manager.path] - ): - err_info = traceback.format_exc() - click.echo(err_info) - - self._launch_console() + network_value = ctx.params.get("network") or networks.default_ecosystem.name + with networks.parse_network_choice(network_value, disconnect_on_exit=False): + if not isinstance(err, ApeException) or not handle_ape_exception( + err, [ctx.obj.project_manager.path] + ): + err_info = traceback.format_exc() + click.echo(err_info) + + self._launch_console() else: # Don't handle error - raise exception as normal. raise diff --git a/tests/functional/test_block_container.py b/tests/functional/test_block_container.py index 68ba9bc3ff..8dbfe42063 100644 --- a/tests/functional/test_block_container.py +++ b/tests/functional/test_block_container.py @@ -147,3 +147,22 @@ def test_poll_blocks_timeout( with pytest.raises(ChainError, match=r"Timed out waiting for new block \(time_waited=1.\d+\)."): with PollDaemon("blocks", poller, lambda x: None, lambda: False): time.sleep(1.5) + + +def test_poll_blocks_future(chain_that_mined_5, eth_tester_provider, owner, PollDaemon): + blocks: Queue = Queue(maxsize=3) + poller = chain_that_mined_5.blocks.poll_blocks( + start_block=chain_that_mined_5.blocks.head.number + 1 + ) + + with PollDaemon("blocks", poller, blocks.put, blocks.full): + # Sleep first to ensure listening before mining. + time.sleep(1) + eth_tester_provider.mine(3) + + assert blocks.full() + first = blocks.get().number + second = blocks.get().number + third = blocks.get().number + assert first == second - 1 + assert second == third - 1 diff --git a/tests/functional/test_network_manager.py b/tests/functional/test_network_manager.py index f67e11433a..579c4db34a 100644 --- a/tests/functional/test_network_manager.py +++ b/tests/functional/test_network_manager.py @@ -149,6 +149,7 @@ def test_repr_disconnected(networks_disconnected): def test_get_provider_from_choice_adhoc_provider(networks_connected_to_tester): uri = "https://geth:1234567890abcdef@geth.foo.bar/" provider = networks_connected_to_tester.get_provider_from_choice(f"ethereum:local:{uri}") + assert uri in provider.connection_id assert provider.name == "geth" assert provider.uri == uri assert provider.network.name == "local" diff --git a/tests/functional/test_plugins.py b/tests/functional/test_plugins.py index 37f163b630..a22883fca0 100644 --- a/tests/functional/test_plugins.py +++ b/tests/functional/test_plugins.py @@ -1,8 +1,15 @@ from typing import Set +from unittest import mock import pytest -from ape_plugins.utils import ApePluginsRepr, PluginMetadata, PluginMetadataList, PluginType +from ape_plugins.utils import ( + ApePluginsRepr, + PluginGroup, + PluginMetadata, + PluginMetadataList, + PluginType, +) CORE_PLUGINS = ("run",) AVAILABLE_PLUGINS = ("available", "installed") @@ -115,8 +122,8 @@ def test_is_available(self): class TestApePluginsRepr: def test_str(self, plugin_metadata): - plugin_map = ApePluginsRepr(plugin_metadata) - actual = str(plugin_map) + representation = ApePluginsRepr(plugin_metadata) + actual = str(representation) expected = f""" Installed Plugins installed {VERSION} @@ -127,8 +134,8 @@ def test_str(self, plugin_metadata): assert actual == expected.strip() def test_str_all_types(self, plugin_metadata): - plugin_map = ApePluginsRepr(plugin_metadata, include=list(PluginType)) - actual = str(plugin_map) + representation = ApePluginsRepr(plugin_metadata, include=list(PluginType)) + actual = str(representation) expected = f""" Core Plugins run @@ -143,3 +150,38 @@ def test_str_all_types(self, plugin_metadata): available """ assert actual == expected.strip() + + def test_str_no_plugins(self): + plugins = PluginMetadataList.from_package_names([]) + representation = ApePluginsRepr(plugins) + assert str(representation) == "" + + +class TestPluginGroup: + def test_name(self): + group = PluginGroup(plugin_type=PluginType.INSTALLED) + assert group.name == "Installed" + + def test_name_when_plugin_type_is_str(self): + group = PluginGroup(plugin_type=PluginType.INSTALLED) + group.plugin_type = PluginType.INSTALLED.value # type: ignore[assignment] + assert group.name == "Installed" + + def test_repr(self): + group = PluginGroup(plugin_type=PluginType.INSTALLED) + assert repr(group) == "" + + def test_repr_when_plugin_type_is_str(self): + group = PluginGroup(plugin_type=PluginType.INSTALLED) + group.plugin_type = PluginType.INSTALLED.value # type: ignore[assignment] + assert repr(group) == "" + + def test_repr_when_exception(self, mocker): + """ + Exceptions CANNOT happen in a repr! + """ + patch = mocker.patch("ape_plugins.utils.PluginGroup.name", new_callable=mock.PropertyMock) + patch.side_effect = ValueError("repr fail test") + group = PluginGroup(plugin_type=PluginType.INSTALLED) + + assert repr(group) == "" diff --git a/tests/integration/cli/projects/script/scripts/error_cli.py b/tests/integration/cli/projects/script/scripts/error_cli.py index 3403a28e8b..e7ad3793d2 100644 --- a/tests/integration/cli/projects/script/scripts/error_cli.py +++ b/tests/integration/cli/projects/script/scripts/error_cli.py @@ -1,7 +1,11 @@ import click +import ape + @click.command(short_help="Use a subcommand") def cli(): local_variable = "test foo bar" # noqa[F841] + provider = ape.chain.provider + provider.set_timestamp(123123123123123123) raise Exception("Expected exception") # noqa: T001 diff --git a/tests/integration/cli/projects/script/scripts/error_main.py b/tests/integration/cli/projects/script/scripts/error_main.py index 2f30e78f75..1f6cfbd208 100644 --- a/tests/integration/cli/projects/script/scripts/error_main.py +++ b/tests/integration/cli/projects/script/scripts/error_main.py @@ -1,3 +1,8 @@ +import ape + + def main(): local_variable = "test foo bar" # noqa[F841] + provider = ape.chain.provider + provider.set_timestamp(123123123123123123) raise Exception("Expected exception") diff --git a/tests/integration/cli/projects/script/scripts/error_no_def.py b/tests/integration/cli/projects/script/scripts/error_no_def.py index 1db3d6ab7a..7e8fae485d 100644 --- a/tests/integration/cli/projects/script/scripts/error_no_def.py +++ b/tests/integration/cli/projects/script/scripts/error_no_def.py @@ -1,2 +1,6 @@ +import ape + local_variable = "test foo bar" # noqa[F841] +provider = ape.chain.provider +provider.set_timestamp(123123123123123123) raise Exception("Expected exception") diff --git a/tests/integration/cli/test_run.py b/tests/integration/cli/test_run.py index b0faf306b4..78ed2340d0 100644 --- a/tests/integration/cli/test_run.py +++ b/tests/integration/cli/test_run.py @@ -100,13 +100,14 @@ def test_run_interactive(ape_cli, runner, project): ] # Show that the variable namespace from the script is available in the console. - user_input = "local_variable\nexit\n" + user_input = "local_variable\nape.chain.provider.mine()\nape.chain.blocks.head\nexit\n" result = runner.invoke(ape_cli, ["run", "--interactive", scripts[0].stem], input=user_input) assert result.exit_code == 0, result.output # From script: local_variable = "test foo bar" assert "test foo bar" in result.output + assert "timestamp=123123123123123" in result.output @skip_projects_except("script")