From 8e7e373c8b990b7e1ab7598fcf06b4b69e05ad31 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 23 Oct 2024 15:44:51 -0500 Subject: [PATCH] perf: make help faster --- src/ape/__init__.py | 72 ++++------ src/ape/_cli.py | 96 ++++++------- src/ape/api/compiler.py | 9 +- src/ape/api/convert.py | 3 +- src/ape/api/projects.py | 3 +- src/ape/cli/choices.py | 108 ++++++++------ src/ape/cli/commands.py | 24 ++-- src/ape/cli/options.py | 187 ++++++++++++++---------- src/ape/contracts/base.py | 2 +- src/ape/exceptions.py | 4 +- src/ape/managers/base.py | 3 +- src/ape/managers/chain.py | 3 +- src/ape/managers/compilers.py | 2 +- src/ape/managers/config.py | 3 +- src/ape/managers/networks.py | 6 +- src/ape/managers/plugins.py | 11 +- src/ape/managers/query.py | 7 +- src/ape/plugins/_utils.py | 9 +- src/ape/plugins/compiler.py | 2 +- src/ape/plugins/config.py | 2 +- src/ape/plugins/converter.py | 2 +- src/ape/plugins/network.py | 4 +- src/ape/plugins/project.py | 2 +- src/ape/plugins/query.py | 2 +- src/ape/pytest/plugin.py | 2 +- src/ape/types/basic.py | 4 +- src/ape/utils/__init__.py | 2 + src/ape/utils/misc.py | 1 + src/ape/utils/trace.py | 2 +- src/ape_accounts/_cli.py | 52 ++++--- src/ape_cache/__init__.py | 15 +- src/ape_cache/_cli.py | 11 +- src/ape_console/_cli.py | 25 ++-- src/ape_console/config.py | 2 +- src/ape_ethereum/__init__.py | 3 +- src/ape_ethereum/multicall/handlers.py | 2 +- src/ape_init/_cli.py | 7 +- src/ape_networks/__init__.py | 2 +- src/ape_networks/_cli.py | 16 ++- src/ape_plugins/__init__.py | 2 +- src/ape_plugins/_cli.py | 16 ++- src/ape_pm/__init__.py | 34 +++-- src/ape_pm/_cli.py | 16 ++- src/ape_pm/dependency.py | 4 +- src/ape_run/_cli.py | 22 +-- src/ape_test/__init__.py | 191 ++++--------------------- src/ape_test/_cli.py | 9 +- src/ape_test/config.py | 168 ++++++++++++++++++++++ src/ape_test/provider.py | 9 +- 49 files changed, 650 insertions(+), 533 deletions(-) create mode 100644 src/ape_test/config.py diff --git a/src/ape/__init__.py b/src/ape/__init__.py index 0136919efe..ffec3155ff 100644 --- a/src/ape/__init__.py +++ b/src/ape/__init__.py @@ -1,56 +1,13 @@ import signal import threading +from typing import Any if threading.current_thread() is threading.main_thread(): # If we are in the main thread, we can safely set the signal handler signal.signal(signal.SIGINT, lambda s, f: _sys.exit(130)) import sys as _sys - -from ape.managers.project import ProjectManager as Project -from ape.pytest.contextmanagers import RevertsContextManager -from ape.utils import ManagerAccessMixin as _ManagerAccessMixin - -# Wiring together the application - -config = _ManagerAccessMixin.config_manager -""" -The active configs for the current project. See :class:`ape.managers.config.ConfigManager`. -""" - -# Main types we export for the user -compilers = _ManagerAccessMixin.compiler_manager -"""Manages compilers for the current project. See -:class:`ape.managers.compilers.CompilerManager`.""" - -networks = _ManagerAccessMixin.network_manager -"""Manages the networks for the current project. See -:class:`ape.managers.networks.NetworkManager`.""" - -chain = _ManagerAccessMixin.chain_manager -""" -The current connected blockchain; requires an active provider. -Useful for development purposes, such as controlling the state of the blockchain. -Also handy for querying data about the chain and managing local caches. -""" - -accounts = _ManagerAccessMixin.account_manager -"""Manages accounts for the current project. See :class:`ape.managers.accounts.AccountManager`.""" - -project = _ManagerAccessMixin.local_project -"""The currently active project. See :class:`ape.managers.project.ProjectManager`.""" - -Contract = chain.contracts.instance_at -"""User-facing class for instantiating contracts.""" - -convert = _ManagerAccessMixin.conversion_manager.convert -"""Conversion utility function. See :class:`ape.managers.converters.ConversionManager`.""" - -reverts = RevertsContextManager -""" -Catch and expect contract logic reverts. Resembles ``pytest.raises()``. -""" - +from importlib import import_module __all__ = [ "accounts", @@ -64,3 +21,28 @@ "Project", # So you can load other projects "reverts", ] + + +def __getattr__(name: str) -> Any: + if name not in __all__: + raise AttributeError(name) + + elif name == "reverts": + contextmanagers = import_module("ape.pytest.contextmanagers") + return contextmanagers.RevertsContextManager + + elif name == "Contract": + access = import_module("ape.utils.basemodel").ManagerAccessMixin + return access.chain_manager.contracts.instance_at + + else: + key = name + if name == "project": + key = "local_project" + elif name.endswith("s"): + key = f"{name[:-1]}_manager" + else: + key = f"{key}_manager" + + basemodel = import_module("ape.utils.basemodel") + return getattr(basemodel.ManagerAccessMixin, key) diff --git a/src/ape/_cli.py b/src/ape/_cli.py index c8cd384de8..50d5e1b186 100644 --- a/src/ape/_cli.py +++ b/src/ape/_cli.py @@ -4,6 +4,7 @@ import warnings from collections.abc import Iterable from gettext import gettext +from importlib import import_module from importlib.metadata import entry_points from pathlib import Path from typing import Any, Optional @@ -13,11 +14,10 @@ import yaml from click import Context -from ape.cli import ape_cli_context +from ape.cli.options import ape_cli_context from ape.exceptions import Abort, ApeException, ConfigError, handle_ape_exception from ape.logging import logger -from ape.plugins._utils import PluginMetadataList, clean_plugin_name -from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.basemodel import ManagerAccessMixin as access _DIFFLIB_CUT_OFF = 0.6 @@ -30,16 +30,16 @@ def display_config(ctx, param, value): click.echo("# Current configuration") # NOTE: Using json-mode as yaml.dump requires JSON-like structure. - model = ManagerAccessMixin.local_project.config_manager.model_dump(mode="json") + model = access.local_project.config.model_dump(mode="json") click.echo(yaml.dump(model)) - ctx.exit() # NOTE: Must exit to bypass running ApeCLI def _validate_config(): + project = access.local_project try: - _ = ManagerAccessMixin.local_project.config + _ = project.config except ConfigError as err: rich.print(err) # Exit now to avoid weird problems. @@ -68,40 +68,40 @@ def format_commands(self, ctx, formatter) -> None: commands.append((subcommand, cmd)) - # Allow for 3 times the default spacing. - if len(commands): - limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) - - # Split the commands into 3 sections. - sections: dict[str, list[tuple[str, str]]] = { - "Core": [], - "Plugin": [], - "3rd-Party Plugin": [], - } - - pl_metadata = PluginMetadataList.load( - ManagerAccessMixin.plugin_manager, include_available=False - ) - - for cli_name, cmd in commands: - help = cmd.get_short_help_str(limit) - plugin = pl_metadata.get_plugin(cli_name) - if not plugin: - continue - - if plugin.in_core: - sections["Core"].append((cli_name, help)) - elif plugin.is_installed and not plugin.is_third_party: - sections["Plugin"].append((cli_name, help)) - else: - sections["3rd-Party Plugin"].append((cli_name, help)) - - for title, rows in sections.items(): - if not rows: - continue - - with formatter.section(gettext(f"{title} Commands")): - formatter.write_dl(rows) + if not commands: + return None + + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) + + # Split the commands into 3 sections. + sections: dict[str, list[tuple[str, str]]] = { + "Core": [], + "Plugin": [], + "3rd-Party Plugin": [], + } + plugin_utils = import_module("ape.plugins._utils") + metadata_cls = plugin_utils.PluginMetadataList + plugin_manager = access.plugin_manager + pl_metadata = metadata_cls.load(plugin_manager, include_available=False) + for cli_name, cmd in commands: + help = cmd.get_short_help_str(limit) + plugin = pl_metadata.get_plugin(cli_name, check_available=False) + if plugin is None: + continue + + if plugin.in_core: + sections["Core"].append((cli_name, help)) + elif plugin.is_installed and not plugin.is_third_party: + sections["Plugin"].append((cli_name, help)) + else: + sections["3rd-Party Plugin"].append((cli_name, help)) + + for title, rows in sections.items(): + if not rows: + continue + + with formatter.section(gettext(f"{title} Commands")): + formatter.write_dl(rows) def invoke(self, ctx) -> Any: try: @@ -158,20 +158,18 @@ def commands(self) -> dict: warnings.simplefilter("ignore") eps = _entry_points.get(self._CLI_GROUP_NAME, []) # type: ignore - self._commands = {clean_plugin_name(cmd.name): cmd.load for cmd in eps} + commands = {cmd.name.replace("_", "-").replace("ape-", ""): cmd.load for cmd in eps} + self._commands = {k: commands[k] for k in sorted(commands)} return self._commands def list_commands(self, ctx) -> list[str]: - return list(sorted(self.commands)) + return [k for k in self.commands] def get_command(self, ctx, name) -> Optional[click.Command]: - if name in self.commands: - try: - return self.commands[name]() - except Exception as err: - logger.warn_from_exception( - err, f"Unable to load CLI endpoint for plugin 'ape_{name}'" - ) + try: + return self.commands[name]() + except Exception as err: + logger.warn_from_exception(err, f"Unable to load CLI endpoint for plugin 'ape_{name}'") # NOTE: don't return anything so Click displays proper error return None diff --git a/src/ape/api/compiler.py b/src/ape/api/compiler.py index 816de6f43a..e870edc745 100644 --- a/src/ape/api/compiler.py +++ b/src/ape/api/compiler.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from collections.abc import Iterable, Iterator from functools import cached_property from pathlib import Path @@ -13,12 +14,8 @@ from ape.exceptions import APINotImplementedError, ContractLogicError from ape.types.coverage import ContractSourceCoverage from ape.types.trace import SourceTraceback -from ape.utils import ( - BaseInterfaceModel, - abstractmethod, - log_instead_of_fail, - raises_not_implemented, -) +from ape.utils.basemodel import BaseInterfaceModel +from ape.utils.misc import log_instead_of_fail, raises_not_implemented if TYPE_CHECKING: from ape.managers.project import ProjectManager diff --git a/src/ape/api/convert.py b/src/ape/api/convert.py index 15fade8575..fa1337d79e 100644 --- a/src/ape/api/convert.py +++ b/src/ape/api/convert.py @@ -1,6 +1,7 @@ +from abc import abstractmethod from typing import Any, Generic, TypeVar -from ape.utils import BaseInterfaceModel, abstractmethod +from ape.utils.basemodel import BaseInterfaceModel ConvertedType = TypeVar("ConvertedType") diff --git a/src/ape/api/projects.py b/src/ape/api/projects.py index 695dbad32d..5349ede9dc 100644 --- a/src/ape/api/projects.py +++ b/src/ape/api/projects.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from functools import cached_property from pathlib import Path from typing import Optional @@ -5,7 +6,7 @@ from pydantic import Field, field_validator from ape.api.config import ApeConfig -from ape.utils import BaseInterfaceModel, abstractmethod +from ape.utils.basemodel import BaseInterfaceModel class DependencyAPI(BaseInterfaceModel): diff --git a/src/ape/cli/choices.py b/src/ape/cli/choices.py index b37894acc3..17a8bec427 100644 --- a/src/ape/cli/choices.py +++ b/src/ape/cli/choices.py @@ -1,39 +1,43 @@ import re from collections.abc import Callable, Iterator, Sequence from enum import Enum -from functools import lru_cache -from typing import Any, Optional, Union +from functools import cached_property, lru_cache +from importlib import import_module +from typing import TYPE_CHECKING, Any, Optional, Union import click from click import BadParameter, Choice, Context, Parameter -from ape.api.accounts import AccountAPI -from ape.api.providers import ProviderAPI from ape.exceptions import ( AccountsError, EcosystemNotFoundError, NetworkNotFoundError, ProviderNotFoundError, ) -from ape.types.basic import _LazySequence -from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.basemodel import ManagerAccessMixin as access + +if TYPE_CHECKING: + from ape.api.accounts import AccountAPI + from ape.api.providers import ProviderAPI _ACCOUNT_TYPE_FILTER = Union[ - None, Sequence[AccountAPI], type[AccountAPI], Callable[[AccountAPI], bool] + None, Sequence["AccountAPI"], type["AccountAPI"], Callable[["AccountAPI"], bool] ] -def _get_accounts(key: _ACCOUNT_TYPE_FILTER) -> list[AccountAPI]: +def _get_accounts(key: _ACCOUNT_TYPE_FILTER) -> list["AccountAPI"]: + accounts = access.account_manager + add_test_accounts = False if key is None: - account_list = list(ManagerAccessMixin.account_manager) + account_list = list(accounts) # Include test accounts at end. add_test_accounts = True elif isinstance(key, type): # Filtering by type. - account_list = ManagerAccessMixin.account_manager.get_accounts_by_type(key) + account_list = accounts.get_accounts_by_type(key) elif isinstance(key, (list, tuple, set)): # Given an account list. @@ -41,11 +45,11 @@ def _get_accounts(key: _ACCOUNT_TYPE_FILTER) -> list[AccountAPI]: else: # Filtering by callable. - account_list = [a for a in ManagerAccessMixin.account_manager if key(a)] # type: ignore + account_list = [a for a in accounts if key(a)] # type: ignore sorted_accounts = sorted(account_list, key=lambda a: a.alias or "") if add_test_accounts: - sorted_accounts.extend(ManagerAccessMixin.account_manager.test_accounts) + sorted_accounts.extend(accounts.test_accounts) return sorted_accounts @@ -64,7 +68,8 @@ def __init__(self, key: _ACCOUNT_TYPE_FILTER = None): # NOTE: we purposely skip the constructor of `Choice` self.case_sensitive = False self._key_filter = key - self.choices = _LazySequence(self._choices_iterator) + module = import_module("ape.types.basic") + self.choices = module._LazySequence(self._choices_iterator) @property def _choices_iterator(self) -> Iterator[str]: @@ -146,7 +151,7 @@ def select(self) -> str: def select_account( prompt_message: Optional[str] = None, key: _ACCOUNT_TYPE_FILTER = None -) -> AccountAPI: +) -> "AccountAPI": """ Prompt the user to pick from their accounts and return that account. Use this method if you want to prompt users to select accounts _outside_ @@ -163,8 +168,8 @@ def select_account( Returns: :class:`~ape.api.accounts.AccountAPI` """ - - if key and isinstance(key, type) and not issubclass(key, AccountAPI): + account_module = import_module("ape.api.accounts") + if key and isinstance(key, type) and not issubclass(key, account_module.AccountAPI): raise AccountsError(f"Cannot return accounts with type '{key}'.") prompt = AccountAliasPromptChoice(prompt_message=prompt_message, key=key) @@ -187,11 +192,12 @@ def __init__( self._key_filter = key self._prompt_message = prompt_message or "Select an account" self.name = name - self.choices = _LazySequence(self._choices_iterator) + module = import_module("ape.types.basic") + self.choices = module._LazySequence(self._choices_iterator) def convert( self, value: Any, param: Optional[Parameter], ctx: Optional[Context] - ) -> Optional[AccountAPI]: + ) -> Optional["AccountAPI"]: if value is None: return None @@ -200,23 +206,24 @@ def convert( else: alias = value + accounts = access.account_manager if isinstance(alias, str) and alias.upper().startswith("TEST::"): idx_str = alias.upper().replace("TEST::", "") if not idx_str.isnumeric(): - if alias in ManagerAccessMixin.account_manager.aliases: + if alias in accounts.aliases: # Was actually a similar-alias. - return ManagerAccessMixin.account_manager.load(alias) + return accounts.load(alias) self.fail(f"Cannot reference test account by '{value}'.", param=param) account_idx = int(idx_str) - if 0 <= account_idx < len(ManagerAccessMixin.account_manager.test_accounts): - return ManagerAccessMixin.account_manager.test_accounts[int(idx_str)] + if 0 <= account_idx < len(accounts.test_accounts): + return accounts.test_accounts[int(idx_str)] self.fail(f"Index '{idx_str}' is not valid.", param=param) - elif alias and alias in ManagerAccessMixin.account_manager.aliases: - return ManagerAccessMixin.account_manager.load(alias) + elif alias and alias in accounts.aliases: + return accounts.load(alias) self.fail(f"Account with alias '{alias}' not found.", param=param) @@ -228,7 +235,8 @@ def print_choices(self): click.echo(f"{idx}. {choice}") did_print = True - len_test_accounts = len(ManagerAccessMixin.account_manager.test_accounts) - 1 + accounts = access.account_manager + len_test_accounts = len(accounts.test_accounts) - 1 if len_test_accounts > 0: msg = "'TEST::account_idx', where `account_idx` is in [0..{len_test_accounts}]\n" if did_print: @@ -246,7 +254,7 @@ def _choices_iterator(self) -> Iterator[str]: if account and (alias := account.alias): yield alias - def select_account(self) -> AccountAPI: + def select_account(self) -> "AccountAPI": """ Returns the selected account. @@ -254,14 +262,13 @@ def select_account(self) -> AccountAPI: :class:`~ape.api.accounts.AccountAPI` """ + accounts = access.account_manager if not self.choices or len(self.choices) == 0: raise AccountsError("No accounts found.") elif len(self.choices) == 1 and self.choices[0].startswith("TEST::"): - return ManagerAccessMixin.account_manager.test_accounts[ - int(self.choices[0].replace("TEST::", "")) - ] + return accounts.test_accounts[int(self.choices[0].replace("TEST::", ""))] elif len(self.choices) == 1: - return ManagerAccessMixin.account_manager.load(self.choices[0]) + return accounts.load(self.choices[0]) self.print_choices() return click.prompt(self._prompt_message, type=self) @@ -278,7 +285,7 @@ def get_networks( ecosystem: _NETWORK_FILTER = None, network: _NETWORK_FILTER = None, provider: _NETWORK_FILTER = None, -) -> _LazySequence: +) -> Sequence: # NOTE: Use str-keys and lru_cache. return _get_networks_sequence_from_cache( _network_filter_to_key(ecosystem), @@ -289,8 +296,10 @@ def get_networks( @lru_cache(maxsize=None) def _get_networks_sequence_from_cache(ecosystem_key: str, network_key: str, provider_key: str): - return _LazySequence( - ManagerAccessMixin.network_manager.get_network_choices( + networks = import_module("ape.utils.basemodel").ManagerAccessMixin.network_manager + module = import_module("ape.types.basic") + return module._LazySequence( + networks.get_network_choices( ecosystem_filter=_key_to_network_filter(ecosystem_key), network_filter=_key_to_network_filter(network_key), provider_filter=_key_to_network_filter(provider_key), @@ -336,23 +345,32 @@ def __init__( ecosystem: _NETWORK_FILTER = None, network: _NETWORK_FILTER = None, provider: _NETWORK_FILTER = None, - base_type: type = ProviderAPI, + base_type: Optional[type] = None, callback: Optional[Callable] = None, ): - if not issubclass(base_type, (ProviderAPI, str)): + provider_module = import_module("ape.api.providers") + base_type = provider_module.ProviderAPI if base_type is None else base_type + if not issubclass(base_type, (provider_module.ProviderAPI, str)): raise TypeError(f"Unhandled type '{base_type}' for NetworkChoice.") self.base_type = base_type self.callback = callback - networks = get_networks(ecosystem=ecosystem, network=network, provider=provider) - super().__init__(networks, case_sensitive) + self.case_sensitive = case_sensitive + self.ecosystem = ecosystem + self.network = network + self.provider = provider + # NOTE: Purposely avoid super().init for performance reasons. + + @cached_property + def choices(self) -> Sequence[Any]: # type: ignore[override] + return get_networks(ecosystem=self.ecosystem, network=self.network, provider=self.provider) def get_metavar(self, param): return "[ecosystem-name][:[network-name][:[provider-name]]]" def convert(self, value: Any, param: Optional[Parameter], ctx: Optional[Context]) -> Any: - choice: Optional[Union[str, ProviderAPI]] - networks = ManagerAccessMixin.network_manager + choice: Optional[Union[str, "ProviderAPI"]] + networks = access.network_manager if not value: choice = None @@ -387,13 +405,11 @@ def convert(self, value: Any, param: Optional[Parameter], ctx: Optional[Context] "Invalid network choice. Use `ape networks list` to see options." ) from err - if ( - choice not in (None, _NONE_NETWORK) - and isinstance(choice, str) - and issubclass(self.base_type, ProviderAPI) - ): - # Return the provider. - choice = networks.get_provider_from_choice(network_choice=value) + if choice not in (None, _NONE_NETWORK) and isinstance(choice, str): + provider_module = import_module("ape.api.providers") + if issubclass(self.base_type, provider_module.ProviderAPI): + # Return the provider. + choice = networks.get_provider_from_choice(network_choice=value) return self.callback(ctx, param, choice) if self.callback else choice diff --git a/src/ape/cli/commands.py b/src/ape/cli/commands.py index 184248a227..00610d2792 100644 --- a/src/ape/cli/commands.py +++ b/src/ape/cli/commands.py @@ -1,13 +1,17 @@ import inspect -from typing import Any, Optional +from importlib import import_module +from typing import TYPE_CHECKING, Any, Optional import click from click import Context -from ape.api import ProviderAPI, ProviderContextManager from ape.cli.choices import _NONE_NETWORK, NetworkChoice from ape.exceptions import NetworkError -from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.basemodel import ManagerAccessMixin as access + +if TYPE_CHECKING: + from ape.api.networks import ProviderContextManager + from ape.api.providers import ProviderAPI def get_param_from_ctx(ctx: Context, param: str) -> Optional[Any]: @@ -21,7 +25,7 @@ def get_param_from_ctx(ctx: Context, param: str) -> Optional[Any]: return None -def parse_network(ctx: Context) -> Optional[ProviderContextManager]: +def parse_network(ctx: Context) -> Optional["ProviderContextManager"]: interactive = get_param_from_ctx(ctx, "interactive") # Handle if already parsed (as when using network-option) @@ -30,17 +34,18 @@ def parse_network(ctx: Context) -> Optional[ProviderContextManager]: return provider.network.use_provider(provider, disconnect_on_exit=not interactive) provider = get_param_from_ctx(ctx, "network") - if provider is not None and isinstance(provider, ProviderAPI): + provider_module = import_module("ape.api.providers") + if provider is not None and isinstance(provider, provider_module.ProviderAPI): return provider.network.use_provider(provider, disconnect_on_exit=not interactive) elif provider not in (None, _NONE_NETWORK) and isinstance(provider, str): # Is using a choice-str network param value instead of the network object instances. - return ManagerAccessMixin.network_manager.parse_network_choice( + return access.network_manager.parse_network_choice( provider, disconnect_on_exit=not interactive ) elif provider is None: - ecosystem = ManagerAccessMixin.network_manager.default_ecosystem + ecosystem = access.network_manager.default_ecosystem network = ecosystem.default_network if provider_name := network.default_provider_name: return network.use_provider(provider_name, disconnect_on_exit=not interactive) @@ -66,7 +71,8 @@ def __init__(self, *args, **kwargs): def parse_args(self, ctx: Context, args: list[str]) -> list[str]: arguments = args # Renamed for better pdb support. - base_type = ProviderAPI if self._use_cls_types else str + provider_module = import_module("ape.api.providers") + base_type = provider_module.ProviderAPI if self._use_cls_types else str if existing_option := next( iter( x @@ -99,7 +105,7 @@ def invoke(self, ctx: Context) -> Any: else: return self._invoke(ctx) - def _invoke(self, ctx: Context, provider: Optional[ProviderAPI] = None): + def _invoke(self, ctx: Context, provider: Optional["ProviderAPI"] = None): # Will be put back with correct value if needed. # Else, causes issues. ctx.params.pop("network", None) diff --git a/src/ape/cli/options.py b/src/ape/cli/options.py index d1e3ec1f06..60f1efafd2 100644 --- a/src/ape/cli/options.py +++ b/src/ape/cli/options.py @@ -1,14 +1,14 @@ import inspect from collections.abc import Callable from functools import partial +from importlib import import_module from pathlib import Path -from typing import NoReturn, Optional, Union +from typing import Any, NoReturn, Optional, Union import click from click import Option from ethpm_types import ContractType -from ape.api.providers import ProviderAPI from ape.cli.choices import ( _ACCOUNT_TYPE_FILTER, _NONE_NETWORK, @@ -41,6 +41,17 @@ def __repr__(self) -> str: # Customizing this because otherwise it uses `dict` repr, which is confusing. return f"<{self.__class__.__name__}>" + def __getattr__(self, item: str) -> Any: + try: + return self.__getattribute__(item) + except AttributeError: + return getattr(self._manager_access, item) + + @property + def _manager_access(self) -> "ManagerAccessMixin": + basemodel = import_module("ape.utils.basemodel") + return basemodel.ManagerAccessMixin + @staticmethod def abort(msg: str, base_error: Optional[Exception] = None) -> NoReturn: """ @@ -167,7 +178,10 @@ def __init__(self, *args, **kwargs) -> None: network = kwargs.pop("network", None) provider = kwargs.pop("provider", None) default = kwargs.pop("default", "auto") - base_type = kwargs.pop("base_type", ProviderAPI) + + provider_module = import_module("ape.api.providers") + base_type = kwargs.pop("base_type", provider_module.ProviderAPI) + callback = kwargs.pop("callback", None) # NOTE: If using network_option, this part is skipped @@ -244,13 +258,7 @@ def network_option( def decorator(f): # These are the available network object names you can request. network_object_names = ("ecosystem", "network", "provider") - - # All kwargs in the defined @click.command(). - command_signature = inspect.signature(f) - command_kwargs = [x.name for x in command_signature.parameters.values()] - - # Any combination of ["ecosystem", "network", "provider"] - requested_network_objects = [x for x in command_kwargs if x in network_object_names] + requested_network_objects = _get_requested_networks(f, network_object_names) # When using network_option, handle parsing now so we can pass to # callback outside of command context. @@ -258,54 +266,10 @@ def decorator(f): def callback(ctx, param, value): keep_as_choice_str = param.type.base_type is str - use_default = value is None and default == "auto" - - if not keep_as_choice_str and use_default: - default_ecosystem = ManagerAccessMixin.network_manager.default_ecosystem - provider_obj = default_ecosystem.default_network.default_provider - - elif value is None or keep_as_choice_str: - provider_obj = None - - elif isinstance(value, ProviderAPI): - provider_obj = value - - elif value == _NONE_NETWORK: - provider_obj = None - - else: - network_ctx = ManagerAccessMixin.network_manager.parse_network_choice(value) - provider_obj = network_ctx._provider + provider_obj = _get_provider(value, default, keep_as_choice_str) if provider_obj: - choice_classes = { - "ecosystem": provider_obj.network.ecosystem, - "network": provider_obj.network, - "provider": provider_obj, - } - - # Set the actual values in the callback. - for item in requested_network_objects: - instance = choice_classes[item] - ctx.params[item] = instance - - if isinstance(ctx.command, ConnectedProviderCommand): - # Place all values, regardless of request in - # the context. This helps the Ape CLI backend. - if ctx.obj is None: - # Happens when using commands that don't use the - # Ape context or any context. - ctx.obj = {} - - for choice, obj in choice_classes.items(): - try: - ctx.obj[choice] = obj - except Exception: - # This would only happen if using an unusual context object. - raise Abort( - "Cannot use connected-provider command type(s) " - "with non key-settable context object." - ) + _update_context_with_network(ctx, provider_obj, requested_network_objects) elif keep_as_choice_str: # Add raw choice to object context. @@ -318,27 +282,7 @@ def callback(ctx, param, value): return value if user_callback is None else user_callback(ctx, param, value) - # Prevent argument errors but initializing callback to use None placeholders. - partial_kwargs: dict = {} - for arg_type in network_object_names: - if arg_type in requested_network_objects: - partial_kwargs[arg_type] = None - - if partial_kwargs: - wrapped_f = partial(f, **partial_kwargs) - - # NOTE: The following is needed for click internals. - wrapped_f.__name__ = f.__name__ # type: ignore[attr-defined] - - # NOTE: The following is needed for sphinx internals. - wrapped_f.__doc__ = f.__doc__ - - # Add other click parameters. - if hasattr(f, "__click_params__"): - wrapped_f.__click_params__ = f.__click_params__ # type: ignore[attr-defined] - else: - # No network kwargs are used. No need for partial wrapper. - wrapped_f = f + wrapped_f = _wrap_network_function(network_object_names, requested_network_objects, f) # Use NetworkChoice option. kwargs["type"] = None @@ -365,6 +309,95 @@ def callback(ctx, param, value): return decorator +def _get_requested_networks(function, network_object_names): + command_signature = inspect.signature(function) + command_kwargs = [x.name for x in command_signature.parameters.values()] + + # Any combination of ["ecosystem", "network", "provider"] + return [x for x in command_kwargs if x in network_object_names] + + +def _update_context_with_network(ctx, provider, requested_network_objects): + choice_classes = { + "ecosystem": provider.network.ecosystem, + "network": provider.network, + "provider": provider, + } + + # Set the actual values in the callback. + for item in requested_network_objects: + instance = choice_classes[item] + ctx.params[item] = instance + + if isinstance(ctx.command, ConnectedProviderCommand): + # Place all values, regardless of request in + # the context. This helps the Ape CLI backend. + if ctx.obj is None: + # Happens when using commands that don't use the + # Ape context or any context. + ctx.obj = {} + + for choice, obj in choice_classes.items(): + try: + ctx.obj[choice] = obj + except Exception: + # This would only happen if using an unusual context object. + raise Abort( + "Cannot use connected-provider command type(s) " + "with non key-settable context object." + ) + + +def _get_provider(value, default, keep_as_choice_str): + use_default = value is None and default == "auto" + provider_module = import_module("ape.api.providers") + ProviderAPI = provider_module.ProviderAPI + + if not keep_as_choice_str and use_default: + default_ecosystem = ManagerAccessMixin.network_manager.default_ecosystem + return default_ecosystem.default_network.default_provider + + elif value is None or keep_as_choice_str: + return None + + elif isinstance(value, ProviderAPI): + return value + + elif value == _NONE_NETWORK: + return None + + else: + network_ctx = ManagerAccessMixin.network_manager.parse_network_choice(value) + return network_ctx._provider + + +def _wrap_network_function(network_object_names, requested_network_objects, function): + # Prevent argument errors but initializing callback to use None placeholders. + partial_kwargs: dict = {} + for arg_type in network_object_names: + if arg_type in requested_network_objects: + partial_kwargs[arg_type] = None + + if partial_kwargs: + wrapped_f = partial(function, **partial_kwargs) + + # NOTE: The following is needed for click internals. + wrapped_f.__name__ = function.__name__ # type: ignore[attr-defined] + + # NOTE: The following is needed for sphinx internals. + wrapped_f.__doc__ = function.__doc__ + + # Add other click parameters. + if hasattr(function, "__click_params__"): + wrapped_f.__click_params__ = function.__click_params__ # type: ignore[attr-defined] + + return wrapped_f + + else: + # No network kwargs are used. No need for partial wrapper. + return function + + def skip_confirmation_option(help="") -> Callable: """ A ``click.option`` for skipping confirmation (``--yes``). diff --git a/src/ape/contracts/base.py b/src/ape/contracts/base.py index f5e834c0ce..1dbe3c0d8f 100644 --- a/src/ape/contracts/base.py +++ b/src/ape/contracts/base.py @@ -45,9 +45,9 @@ ManagerAccessMixin, _assert_not_ipython_check, get_attribute_with_extras, - log_instead_of_fail, only_raise_attribute_error, ) +from ape.utils.misc import log_instead_of_fail class ContractConstructor(ManagerAccessMixin): diff --git a/src/ape/exceptions.py b/src/ape/exceptions.py index b6f1db0dcd..41f6a7f247 100644 --- a/src/ape/exceptions.py +++ b/src/ape/exceptions.py @@ -5,6 +5,7 @@ import traceback from collections.abc import Collection, Iterable from functools import cached_property +from importlib import import_module from inspect import getframeinfo, stack from pathlib import Path from types import CodeType, TracebackType @@ -920,7 +921,8 @@ def _get_custom_python_traceback( # https://github.com/pallets/jinja/blob/main/src/jinja2/debug.py#L142 if project is None: - from ape import project + access = import_module("ape.utils.basemodel").ManagerAccessMixin + project = access.local_project if not (base_path := getattr(project, "path", None)): # TODO: Add support for manifest-projects. diff --git a/src/ape/managers/base.py b/src/ape/managers/base.py index 75371dd980..20f0e80efe 100644 --- a/src/ape/managers/base.py +++ b/src/ape/managers/base.py @@ -1,4 +1,5 @@ -from ape.utils import ManagerAccessMixin, raises_not_implemented +from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.misc import raises_not_implemented class BaseManager(ManagerAccessMixin): diff --git a/src/ape/managers/chain.py b/src/ape/managers/chain.py index 45682de78e..fbfde6f326 100644 --- a/src/ape/managers/chain.py +++ b/src/ape/managers/chain.py @@ -15,9 +15,9 @@ from rich.console import Console as RichConsole from rich.table import Table -from ape.api import BlockAPI, ReceiptAPI from ape.api.address import BaseAddress from ape.api.networks import NetworkAPI, ProxyInfoAPI +from ape.api.providers import BlockAPI from ape.api.query import ( AccountTransactionQuery, BlockQuery, @@ -26,6 +26,7 @@ extract_fields, validate_and_expand_columns, ) +from ape.api.transactions import ReceiptAPI from ape.contracts import ContractContainer, ContractInstance from ape.exceptions import ( APINotImplementedError, diff --git a/src/ape/managers/compilers.py b/src/ape/managers/compilers.py index a57e5f7f13..09cc989fa4 100644 --- a/src/ape/managers/compilers.py +++ b/src/ape/managers/compilers.py @@ -13,13 +13,13 @@ from ape.exceptions import CompilerError, ContractLogicError, CustomError from ape.logging import logger from ape.managers.base import BaseManager -from ape.utils import log_instead_of_fail from ape.utils.basemodel import ( ExtraAttributesMixin, ExtraModelAttributes, get_attribute_with_extras, only_raise_attribute_error, ) +from ape.utils.misc import log_instead_of_fail from ape.utils.os import get_full_extension if TYPE_CHECKING: diff --git a/src/ape/managers/config.py b/src/ape/managers/config.py index d6a5a357a0..7739703008 100644 --- a/src/ape/managers/config.py +++ b/src/ape/managers/config.py @@ -9,7 +9,6 @@ from ape.api.config import ApeConfig from ape.managers.base import BaseManager -from ape.utils import create_tempdir, in_tempdir, log_instead_of_fail from ape.utils.basemodel import ( ExtraAttributesMixin, ExtraModelAttributes, @@ -17,6 +16,8 @@ get_item_with_extras, only_raise_attribute_error, ) +from ape.utils.misc import log_instead_of_fail +from ape.utils.os import create_tempdir, in_tempdir from ape.utils.rpc import RPCHeaders CONFIG_FILE_NAME = "ape-config.yaml" diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index a5da8eb96f..b116140690 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -2,11 +2,10 @@ from functools import cached_property from typing import Optional, Union -from ape.api import EcosystemAPI, ProviderAPI, ProviderContextManager -from ape.api.networks import NetworkAPI +from ape.api.networks import EcosystemAPI, NetworkAPI, ProviderContextManager +from ape.api.providers import ProviderAPI from ape.exceptions import EcosystemNotFoundError, NetworkError, NetworkNotFoundError from ape.managers.base import BaseManager -from ape.utils import RPCHeaders from ape.utils.basemodel import ( ExtraAttributesMixin, ExtraModelAttributes, @@ -14,6 +13,7 @@ only_raise_attribute_error, ) from ape.utils.misc import _dict_overlay, log_instead_of_fail +from ape.utils.rpc import RPCHeaders from ape_ethereum.provider import EthereumNodeProvider diff --git a/src/ape/managers/plugins.py b/src/ape/managers/plugins.py index 66f0fbf7f4..6c3f4f4494 100644 --- a/src/ape/managers/plugins.py +++ b/src/ape/managers/plugins.py @@ -1,5 +1,6 @@ -import importlib from collections.abc import Generator, Iterable, Iterator +from functools import cached_property +from importlib import import_module from typing import Any, Optional from ape.exceptions import ApeAttributeError @@ -113,10 +114,10 @@ def get_plugin_name_and_hookfn(h): if validated_plugin: yield validated_plugin - @property + @cached_property def registered_plugins(self) -> set[str]: - self._register_plugins() - return {x[0] for x in pluggy_manager.list_name_plugin()} + plugins = list({n.replace("-", "_") for n in get_plugin_dists()}) + return {*plugins, *CORE_PLUGINS} def _register_plugins(self): if self.__registered: @@ -127,7 +128,7 @@ def _register_plugins(self): for module_name in plugin_modules: try: - module = importlib.import_module(module_name) + module = import_module(module_name) pluggy_manager.register(module) except Exception as err: if module_name in CORE_PLUGINS or module_name == "ape": diff --git a/src/ape/managers/query.py b/src/ape/managers/query.py index 17ba3c36bd..86cd971b2c 100644 --- a/src/ape/managers/query.py +++ b/src/ape/managers/query.py @@ -1,22 +1,25 @@ import difflib import time from collections.abc import Iterator +from functools import cached_property, singledispatchmethod from itertools import tee from typing import Optional -from ape.api import QueryAPI, QueryType, ReceiptAPI, TransactionAPI from ape.api.query import ( AccountTransactionQuery, BaseInterfaceModel, BlockQuery, BlockTransactionQuery, ContractEventQuery, + QueryAPI, + QueryType, ) +from ape.api.transactions import ReceiptAPI, TransactionAPI from ape.contracts.base import ContractLog, LogFilter from ape.exceptions import QueryEngineError from ape.logging import logger from ape.plugins._utils import clean_plugin_name -from ape.utils import ManagerAccessMixin, cached_property, singledispatchmethod +from ape.utils.basemodel import ManagerAccessMixin class DefaultQueryProvider(QueryAPI): diff --git a/src/ape/plugins/_utils.py b/src/ape/plugins/_utils.py index af0b1c1665..5e71d39fde 100644 --- a/src/ape/plugins/_utils.py +++ b/src/ape/plugins/_utils.py @@ -14,10 +14,9 @@ from ape.exceptions import PluginVersionError from ape.logging import logger -from ape.utils import BaseInterfaceModel, get_package_version, log_instead_of_fail from ape.utils._github import github_client -from ape.utils.basemodel import BaseModel -from ape.utils.misc import _get_distributions +from ape.utils.basemodel import BaseInterfaceModel, BaseModel +from ape.utils.misc import _get_distributions, get_package_version, log_instead_of_fail from ape.version import version as ape_version_str # Plugins maintained OSS by ApeWorX (and trusted) @@ -215,7 +214,7 @@ def all_plugins(self) -> Iterator["PluginMetadata"]: yield from self.installed.plugins.values() yield from self.third_party.plugins.values() - def get_plugin(self, name: str) -> Optional["PluginMetadata"]: + def get_plugin(self, name: str, check_available: bool = True) -> Optional["PluginMetadata"]: name = name if name.startswith("ape_") else f"ape_{name}" if name in self.core.plugins: return self.core.plugins[name] @@ -223,7 +222,7 @@ def get_plugin(self, name: str) -> Optional["PluginMetadata"]: return self.installed.plugins[name] elif name in self.third_party.plugins: return self.third_party.plugins[name] - elif name in self.available.plugins: + elif check_available and name in self.available.plugins: return self.available.plugins[name] return None diff --git a/src/ape/plugins/compiler.py b/src/ape/plugins/compiler.py index 32ced18b13..650e8bee3f 100644 --- a/src/ape/plugins/compiler.py +++ b/src/ape/plugins/compiler.py @@ -1,4 +1,4 @@ -from ape.api import CompilerAPI +from ape.api.compiler import CompilerAPI from .pluggy_patch import PluginType, hookspec diff --git a/src/ape/plugins/config.py b/src/ape/plugins/config.py index 5e531d453b..933d407729 100644 --- a/src/ape/plugins/config.py +++ b/src/ape/plugins/config.py @@ -1,4 +1,4 @@ -from ape.api import PluginConfig +from ape.api.config import PluginConfig from .pluggy_patch import PluginType, hookspec diff --git a/src/ape/plugins/converter.py b/src/ape/plugins/converter.py index 87f14f6316..ac2a01232d 100644 --- a/src/ape/plugins/converter.py +++ b/src/ape/plugins/converter.py @@ -1,6 +1,6 @@ from collections.abc import Iterator -from ape.api import ConverterAPI +from ape.api.convert import ConverterAPI from .pluggy_patch import PluginType, hookspec diff --git a/src/ape/plugins/network.py b/src/ape/plugins/network.py index f00f903bac..45aa93e8c2 100644 --- a/src/ape/plugins/network.py +++ b/src/ape/plugins/network.py @@ -1,6 +1,8 @@ from collections.abc import Iterator -from ape.api import EcosystemAPI, ExplorerAPI, NetworkAPI, ProviderAPI +from ape.api.explorers import ExplorerAPI +from ape.api.networks import EcosystemAPI, NetworkAPI +from ape.api.providers import ProviderAPI from .pluggy_patch import PluginType, hookspec diff --git a/src/ape/plugins/project.py b/src/ape/plugins/project.py index 5510310719..32c14a4f54 100644 --- a/src/ape/plugins/project.py +++ b/src/ape/plugins/project.py @@ -1,6 +1,6 @@ from collections.abc import Iterator -from ape.api import DependencyAPI, ProjectAPI +from ape.api.projects import DependencyAPI, ProjectAPI from .pluggy_patch import PluginType, hookspec diff --git a/src/ape/plugins/query.py b/src/ape/plugins/query.py index 02d5a0af57..ae8d97a4d4 100644 --- a/src/ape/plugins/query.py +++ b/src/ape/plugins/query.py @@ -4,7 +4,7 @@ from .pluggy_patch import PluginType, hookspec if TYPE_CHECKING: - from ape.api import QueryAPI + from ape.api.query import QueryAPI class QueryPlugin(PluginType): diff --git a/src/ape/pytest/plugin.py b/src/ape/pytest/plugin.py index d470dbfe44..85d5febd5d 100644 --- a/src/ape/pytest/plugin.py +++ b/src/ape/pytest/plugin.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Optional -from ape.api import EcosystemAPI +from ape.api.networks import EcosystemAPI from ape.exceptions import ConfigError from ape.pytest.config import ConfigWrapper from ape.pytest.coverage import CoverageTracker diff --git a/src/ape/types/basic.py b/src/ape/types/basic.py index 920c52e53b..cdd8a42698 100644 --- a/src/ape/types/basic.py +++ b/src/ape/types/basic.py @@ -6,8 +6,8 @@ def _hex_int_validator(value, info): - basemodel = import_module("ape.utils.basemodel") - convert = basemodel.ManagerAccessMixin.conversion_manager.convert + access = import_module("ape.utils.basemodel").ManagerAccessMixin + convert = access.conversion_manager.convert return convert(value, int) diff --git a/src/ape/utils/__init__.py b/src/ape/utils/__init__.py index 979e32b84e..db5f197e05 100644 --- a/src/ape/utils/__init__.py +++ b/src/ape/utils/__init__.py @@ -24,6 +24,7 @@ DEFAULT_LOCAL_TRANSACTION_ACCEPTANCE_TIMEOUT, DEFAULT_TRANSACTION_ACCEPTANCE_TIMEOUT, EMPTY_BYTES32, + LOCAL_NETWORK_NAME, SOURCE_EXCLUDE_PATTERNS, ZERO_ADDRESS, add_padding_to_strings, @@ -115,6 +116,7 @@ "is_zero_hex", "JoinableQueue", "load_config", + "LOCAL_NETWORK_NAME", "log_instead_of_fail", "LogInputABICollection", "ManagerAccessMixin", diff --git a/src/ape/utils/misc.py b/src/ape/utils/misc.py index ee99d6f761..6fae748b61 100644 --- a/src/ape/utils/misc.py +++ b/src/ape/utils/misc.py @@ -532,6 +532,7 @@ def as_our_module(cls_or_def: _MOD_T, doc_str: Optional[str] = None) -> _MOD_T: "is_evm_precompile", "is_zero_hex", "load_config", + "LOCAL_NETWORK_NAME", "log_instead_of_fail", "nonreentrant", "raises_not_implemented", diff --git a/src/ape/utils/trace.py b/src/ape/utils/trace.py index 70ad46ab3a..f6dc0bf5b6 100644 --- a/src/ape/utils/trace.py +++ b/src/ape/utils/trace.py @@ -6,7 +6,7 @@ from rich.box import SIMPLE from rich.table import Table -from ape.utils import is_evm_precompile, is_zero_hex +from ape.utils.misc import is_evm_precompile, is_zero_hex if TYPE_CHECKING: from ape.types.coverage import CoverageReport diff --git a/src/ape_accounts/_cli.py b/src/ape_accounts/_cli.py index a0013ee179..27fd75cb67 100644 --- a/src/ape_accounts/_cli.py +++ b/src/ape_accounts/_cli.py @@ -1,28 +1,25 @@ import json -from typing import Optional +from importlib import import_module +from typing import TYPE_CHECKING, Optional import click from eth_account import Account as EthAccount from eth_account.hdaccount import ETHEREUM_DEFAULT_PATH from eth_utils import to_checksum_address, to_hex -from ape.cli import ape_cli_context, existing_alias_argument, non_existing_alias_argument +from ape.cli.arguments import existing_alias_argument, non_existing_alias_argument +from ape.cli.options import ape_cli_context from ape.logging import HIDDEN_MESSAGE -from ape.utils.basemodel import ManagerAccessMixin -from ape_accounts import ( - AccountContainer, - KeyfileAccount, - generate_account, - import_account_from_mnemonic, - import_account_from_private_key, -) +from ape.utils.basemodel import ManagerAccessMixin as access + +if TYPE_CHECKING: + from ape.api.accounts import AccountAPI + from ape_accounts.accounts import AccountContainer, KeyfileAccount -def _get_container() -> AccountContainer: +def _get_container() -> "AccountContainer": # NOTE: Must used the instantiated version of `AccountsContainer` in `accounts` - container = ManagerAccessMixin.account_manager.containers["accounts"] - assert isinstance(container, AccountContainer) - return container + return access.account_manager.containers["accounts"] @click.group(short_help="Manage local accounts") @@ -109,7 +106,10 @@ def generate(cli_ctx, alias, hide_mnemonic, word_count, custom_hd_path): confirmation_prompt=True, ) - account, mnemonic = generate_account(alias, passphrase, custom_hd_path, word_count) + account_module = import_module("ape_accounts.accounts") + account, mnemonic = account_module.generate_account( + alias, passphrase, custom_hd_path, word_count + ) if show_mnemonic: cli_ctx.logger.info(f"Newly generated mnemonic is: {click.style(mnemonic, bold=True)}") @@ -135,7 +135,7 @@ def generate(cli_ctx, alias, hide_mnemonic, word_count, custom_hd_path): ) @non_existing_alias_argument() def _import(cli_ctx, alias, import_from_mnemonic, custom_hd_path): - account: Optional[KeyfileAccount] = None + account: Optional["KeyfileAccount"] = None def ask_for_passphrase(): return click.prompt( @@ -144,12 +144,15 @@ def ask_for_passphrase(): confirmation_prompt=True, ) + account_module = import_module("ape_accounts.accounts") if import_from_mnemonic: mnemonic = click.prompt("Enter mnemonic seed phrase", hide_input=True) EthAccount.enable_unaudited_hdwallet_features() try: passphrase = ask_for_passphrase() - account = import_account_from_mnemonic(alias, passphrase, mnemonic, custom_hd_path) + account = account_module.import_account_from_mnemonic( + alias, passphrase, mnemonic, custom_hd_path + ) except Exception as error: error_msg = f"{error}".replace(mnemonic, HIDDEN_MESSAGE) cli_ctx.abort(f"Seed phrase can't be imported: {error_msg}") @@ -158,7 +161,7 @@ def ask_for_passphrase(): key = click.prompt("Enter Private Key", hide_input=True) try: passphrase = ask_for_passphrase() - account = import_account_from_private_key(alias, passphrase, key) + account = account_module.import_account_from_private_key(alias, passphrase, key) except Exception as error: cli_ctx.abort(f"Key can't be imported: {error}") @@ -168,9 +171,14 @@ def ask_for_passphrase(): ) +def _load_account_type(account: "AccountAPI") -> bool: + module = import_module("ape_accounts.accounts") + return isinstance(account, module.KeyfileAccount) + + @cli.command(short_help="Export an account private key") @ape_cli_context() -@existing_alias_argument(account_type=KeyfileAccount) +@existing_alias_argument(account_type=_load_account_type) def export(cli_ctx, alias): path = _get_container().data_folder.joinpath(f"{alias}.json") account = json.loads(path.read_text()) @@ -184,19 +192,17 @@ def export(cli_ctx, alias): @cli.command(short_help="Change the password of an existing account") @ape_cli_context() -@existing_alias_argument(account_type=KeyfileAccount) +@existing_alias_argument(account_type=_load_account_type) def change_password(cli_ctx, alias): account = cli_ctx.account_manager.load(alias) - assert isinstance(account, KeyfileAccount) account.change_password() cli_ctx.logger.success(f"Password has been changed for account '{alias}'") @cli.command(short_help="Delete an existing account") @ape_cli_context() -@existing_alias_argument(account_type=KeyfileAccount) +@existing_alias_argument(account_type=_load_account_type) def delete(cli_ctx, alias): account = cli_ctx.account_manager.load(alias) - assert isinstance(account, KeyfileAccount) account.delete() cli_ctx.logger.success(f"Account '{alias}' has been deleted") diff --git a/src/ape_cache/__init__.py b/src/ape_cache/__init__.py index 4bea9fd8ea..ea5fe10e1d 100644 --- a/src/ape_cache/__init__.py +++ b/src/ape_cache/__init__.py @@ -1,7 +1,7 @@ -from ape import plugins -from ape.api import PluginConfig +from importlib import import_module -from .query import CacheQueryProvider +from ape import plugins +from ape.api.config import PluginConfig class CacheConfig(PluginConfig): @@ -15,7 +15,14 @@ def config_class(): @plugins.register(plugins.QueryPlugin) def query_engines(): - return CacheQueryProvider + query = import_module("ape_cache.query") + return query.CacheQueryProvider + + +def __getattr__(name): + if name == "CacheQueryProvider": + query = import_module("ape_cache.query") + return query.CacheQueryProvider __all__ = [ diff --git a/src/ape_cache/_cli.py b/src/ape_cache/_cli.py index 8e859ed286..20aa47ccf7 100644 --- a/src/ape_cache/_cli.py +++ b/src/ape_cache/_cli.py @@ -1,13 +1,15 @@ +from importlib import import_module + import click -import pandas as pd -from ape.cli import ConnectedProviderCommand, network_option +from ape.cli.commands import ConnectedProviderCommand +from ape.cli.options import network_option from ape.logging import logger -from ape.utils import ManagerAccessMixin def get_engine(): - return ManagerAccessMixin.query_manager.engines["cache"] + basemodel = import_module("ape.utils.basemodel") + return basemodel.ManagerAccessMixin.query_manager.engines["cache"] @click.group(short_help="Query from caching database") @@ -51,6 +53,7 @@ def query(query_str): with get_engine().database_connection as conn: results = conn.execute(query_str).fetchall() if results: + pd = import_module("pandas") click.echo(pd.DataFrame(results)) diff --git a/src/ape_console/_cli.py b/src/ape_console/_cli.py index 7e65dbb316..bb1618a2e0 100644 --- a/src/ape_console/_cli.py +++ b/src/ape_console/_cli.py @@ -6,20 +6,22 @@ from importlib.util import module_from_spec, spec_from_loader from os import environ from types import ModuleType -from typing import Any, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast import click -import IPython -from IPython.terminal.ipapp import Config as IPythonConfig from ape.cli.commands import ConnectedProviderCommand from ape.cli.options import ape_cli_context, project_option -from ape.managers.project import ProjectManager -from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.basemodel import ManagerAccessMixin as access from ape.utils.misc import _python_version from ape.version import version as ape_version from ape_console.config import ConsoleConfig +if TYPE_CHECKING: + from IPython.terminal.ipapp import Config as IPythonConfig + + from ape.managers.project import ProjectManager + CONSOLE_EXTRAS_FILENAME = "ape_console_extras.py" @@ -52,7 +54,7 @@ def import_extras_file(file_path) -> ModuleType: def load_console_extras(**namespace: Any) -> dict[str, Any]: """load and return namespace updates from ape_console_extras.py files if they exist""" - pm = namespace.get("project", ManagerAccessMixin.local_project) + pm = namespace.get("project", access.local_project) global_extras = pm.config_manager.DATA_FOLDER.joinpath(CONSOLE_EXTRAS_FILENAME) project_extras = pm.path.joinpath(CONSOLE_EXTRAS_FILENAME) @@ -91,14 +93,17 @@ def load_console_extras(**namespace: Any) -> dict[str, Any]: def console( - project: Optional[ProjectManager] = None, + project: Optional["ProjectManager"] = None, verbose: bool = False, extra_locals: Optional[dict] = None, embed: bool = False, ): + import IPython + from IPython.terminal.ipapp import Config as IPythonConfig + import ape - project = project or ManagerAccessMixin.local_project + project = project or ape.local_project banner = "" if verbose: banner = """ @@ -147,7 +152,9 @@ def console( _launch_console(namespace, ipy_config, embed, banner) -def _launch_console(namespace: dict, ipy_config: IPythonConfig, embed: bool, banner: str): +def _launch_console(namespace: dict, ipy_config: "IPythonConfig", embed: bool, banner: str): + import IPython + ipython_kwargs = {"user_ns": namespace, "config": ipy_config} if embed: IPython.embed(**ipython_kwargs, colors="Neutral", banner1=banner) diff --git a/src/ape_console/config.py b/src/ape_console/config.py index 1290241dc2..48b432ced2 100644 --- a/src/ape_console/config.py +++ b/src/ape_console/config.py @@ -1,4 +1,4 @@ -from ape.api import PluginConfig +from ape.api.config import PluginConfig class ConsoleConfig(PluginConfig): diff --git a/src/ape_ethereum/__init__.py b/src/ape_ethereum/__init__.py index 599936f191..552427d10e 100644 --- a/src/ape_ethereum/__init__.py +++ b/src/ape_ethereum/__init__.py @@ -1,6 +1,5 @@ from ape import plugins from ape.api.networks import ForkedNetworkAPI, NetworkAPI, create_network_type -from ape.utils.misc import LOCAL_NETWORK_NAME from ._converters import WeiConversions from .ecosystem import ( @@ -50,7 +49,7 @@ def networks(): yield "ethereum", f"{network_name}-fork", ForkedNetworkAPI # NOTE: This works for local providers, as they get chain_id from themselves - yield "ethereum", LOCAL_NETWORK_NAME, NetworkAPI + yield "ethereum", "local", NetworkAPI @plugins.register(plugins.QueryPlugin) diff --git a/src/ape_ethereum/multicall/handlers.py b/src/ape_ethereum/multicall/handlers.py index ae6ed6ec96..b8722fe7e5 100644 --- a/src/ape_ethereum/multicall/handlers.py +++ b/src/ape_ethereum/multicall/handlers.py @@ -6,7 +6,7 @@ from eth_pydantic_types import HexBytes from ethpm_types import ContractType -from ape.api import ReceiptAPI, TransactionAPI +from ape.api.transactions import ReceiptAPI, TransactionAPI from ape.contracts.base import ( ContractCallHandler, ContractInstance, diff --git a/src/ape_init/_cli.py b/src/ape_init/_cli.py index 1d6fcd6673..1102dbb9e7 100644 --- a/src/ape_init/_cli.py +++ b/src/ape_init/_cli.py @@ -4,8 +4,7 @@ import click from click import BadParameter -from ape.cli import ape_cli_context -from ape.managers.config import CONFIG_FILE_NAME +from ape.cli.options import ape_cli_context from ape.utils._github import github_client GITIGNORE_CONTENT = """ @@ -73,10 +72,10 @@ def cli(cli_ctx, github): git_ignore_path.touch() git_ignore_path.write_text(GITIGNORE_CONTENT.lstrip(), encoding="utf8") - ape_config = project_folder / CONFIG_FILE_NAME + ape_config = project_folder / "ape-config.yaml" if ape_config.exists(): cli_ctx.logger.warning(f"'{ape_config}' exists") else: project_name = click.prompt("Please enter project name") ape_config.write_text(f"name: {project_name}\n", encoding="utf8") - cli_ctx.logger.success(f"{project_name} is written in {CONFIG_FILE_NAME}") + cli_ctx.logger.success(f"{project_name} is written in ape-config.yaml") diff --git a/src/ape_networks/__init__.py b/src/ape_networks/__init__.py index cbac22e661..f82dc72c30 100644 --- a/src/ape_networks/__init__.py +++ b/src/ape_networks/__init__.py @@ -1,7 +1,7 @@ from typing import Optional from ape import plugins -from ape.api import PluginConfig +from ape.api.config import PluginConfig class CustomNetwork(PluginConfig): diff --git a/src/ape_networks/_cli.py b/src/ape_networks/_cli.py index 68e9beb092..fd9c019787 100644 --- a/src/ape_networks/_cli.py +++ b/src/ape_networks/_cli.py @@ -1,18 +1,22 @@ import json from collections.abc import Callable +from importlib import import_module +from typing import TYPE_CHECKING import click import yaml from rich import print as echo_rich_text from rich.tree import Tree -from ape.api.providers import SubprocessProvider from ape.cli.choices import OutputFormat from ape.cli.options import ape_cli_context, network_option, output_format_option from ape.exceptions import NetworkError from ape.logging import LogLevel from ape.types.basic import _LazySequence -from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.basemodel import ManagerAccessMixin as access + +if TYPE_CHECKING: + from ape.api.providers import SubprocessProvider def _filter_option(name: str, options): @@ -35,7 +39,7 @@ def cli(): def _lazy_get(name: str) -> _LazySequence: # NOTE: Using fn generator to maintain laziness. def gen(): - yield from getattr(ManagerAccessMixin.network_manager, f"{name}_names") + yield from getattr(access.network_manager, f"{name}_names") return _LazySequence(gen) @@ -114,8 +118,8 @@ def run(cli_ctx, provider): """ # Ignore extra loggers, such as web3 loggers. cli_ctx.logger._extra_loggers = {} - - if not isinstance(provider, SubprocessProvider): + providers_module = import_module("ape.api.providers") + if not isinstance(provider, providers_module.SubprocessProvider): cli_ctx.abort( f"`ape networks run` requires a provider that manages a process, not '{provider.name}'." ) @@ -136,7 +140,7 @@ def run(cli_ctx, provider): cli_ctx.logger.format(fmt=original_format) -def _run(cli_ctx, provider: SubprocessProvider): +def _run(cli_ctx, provider: "SubprocessProvider"): provider.connect() if process := provider.process: try: diff --git a/src/ape_plugins/__init__.py b/src/ape_plugins/__init__.py index 718cba4bcb..8826fdd2ce 100644 --- a/src/ape_plugins/__init__.py +++ b/src/ape_plugins/__init__.py @@ -1,5 +1,5 @@ from ape import plugins -from ape.api import ConfigDict +from ape.api.config import ConfigDict @plugins.register(plugins.Config) diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index 60e563e419..b4c27b1a81 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -6,9 +6,8 @@ import click from packaging.version import Version -from ape.cli import ape_cli_context, skip_confirmation_option +from ape.cli.options import ape_cli_context, skip_confirmation_option from ape.logging import logger -from ape.managers.config import CONFIG_FILE_NAME from ape.plugins._utils import ( PIP_COMMAND, ModifyPluginResultHandler, @@ -35,8 +34,17 @@ def plugins_argument(): """ def load_from_file(ctx, file_path: Path) -> list[PluginMetadata]: - if file_path.is_dir() and (file_path / CONFIG_FILE_NAME).is_file(): - file_path = file_path / CONFIG_FILE_NAME + if file_path.is_dir(): + name_options = ( + "ape-config.yaml", + "ape-config.yml", + "ape-config.json", + "pyproject.toml", + ) + for option in name_options: + if (file_path / option).is_file(): + file_path = file_path / option + break if file_path.is_file(): config = load_config(file_path) diff --git a/src/ape_pm/__init__.py b/src/ape_pm/__init__.py index ef9bc44f66..14230f6681 100644 --- a/src/ape_pm/__init__.py +++ b/src/ape_pm/__init__.py @@ -1,27 +1,39 @@ -from ape import plugins +from importlib import import_module -from .compiler import InterfaceCompiler -from .dependency import GithubDependency, LocalDependency, NpmDependency, PythonDependency -from .projects import BrownieProject, FoundryProject +from ape import plugins @plugins.register(plugins.CompilerPlugin) def register_compiler(): - return (".json",), InterfaceCompiler + compiler = import_module("ape_pm.compiler") + return (".json",), compiler.InterfaceCompiler @plugins.register(plugins.DependencyPlugin) def dependencies(): - yield "github", GithubDependency - yield "local", LocalDependency - yield "npm", NpmDependency - yield ("python", "pypi"), PythonDependency + _dependencies = import_module("ape_pm.projects") + yield "github", _dependencies.GithubDependency + yield "local", _dependencies.LocalDependency + yield "npm", _dependencies.NpmDependency + yield ("python", "pypi"), _dependencies.PythonDependency @plugins.register(plugins.ProjectPlugin) def projects(): - yield BrownieProject - yield FoundryProject + _projects = import_module("ape_pm.projects") + yield _projects.BrownieProject + yield _projects.FoundryProject + + +def __getattr__(name: str): + if name in ("BrownieProject", "FoundryProject"): + module = import_module("ape_pm.projects") + elif name in ("GithubDependency", "LocalDependency", "NpmDependency", "PythonDependency"): + module = import_module("ape_pm.projects") + else: + module = import_module("ape_pm.compiler") + + return getattr(module, name) __all__ = [ diff --git a/src/ape_pm/_cli.py b/src/ape_pm/_cli.py index 78326521c5..c87e268adc 100644 --- a/src/ape_pm/_cli.py +++ b/src/ape_pm/_cli.py @@ -1,14 +1,16 @@ import sys +from importlib import import_module from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional import click from ape.cli.options import ape_cli_context, config_override_option from ape.exceptions import ProjectError from ape.logging import logger -from ape.managers.project import Dependency -from ape_pm import LocalDependency + +if TYPE_CHECKING: + from ape.managers.project import Dependency @click.group() @@ -47,9 +49,10 @@ def _list(cli_ctx, list_all): # For local dependencies, use the short name. # This is mostly because it looks nicer than very long paths. + dependency_module = import_module("ape_pm.dependency") name = ( dependency.name - if isinstance(dependency.api, LocalDependency) + if isinstance(dependency.api, dependency_module.LocalDependency) else dependency.package_id ) @@ -197,7 +200,6 @@ def install(cli_ctx, package, name, version, ref, force, config_override): except Exception as err: cli_ctx.logger.log_error(err) else: - assert isinstance(dependency, Dependency) # for mypy cli_ctx.logger.success(f"Package '{dependency.name}@{dependency.version}' installed.") @@ -277,7 +279,7 @@ def uninstall(cli_ctx, name, versions, yes): sys.exit(int(did_error)) -def _uninstall(dependency: Dependency, yes: bool = False) -> bool: +def _uninstall(dependency: "Dependency", yes: bool = False) -> bool: key = f"{dependency.name}={dependency.version}" if not yes and not click.confirm(f"Remove '{key}'"): return True # Not an error. @@ -337,7 +339,7 @@ def compile(cli_ctx, name, version, force, config_override): _compile_dependency(cli_ctx, dependency, force) -def _compile_dependency(cli_ctx, dependency: Dependency, force: bool): +def _compile_dependency(cli_ctx, dependency: "Dependency", force: bool): try: result = dependency.compile(use_cache=not force) except Exception as err: diff --git a/src/ape_pm/dependency.py b/src/ape_pm/dependency.py index 5d2c336266..064237e044 100644 --- a/src/ape_pm/dependency.py +++ b/src/ape_pm/dependency.py @@ -14,9 +14,9 @@ from ape.exceptions import ProjectError from ape.logging import logger from ape.managers.project import _version_to_options -from ape.utils import ManagerAccessMixin, clean_path, get_package_path, in_tempdir from ape.utils._github import _GithubClient, github_client -from ape.utils.os import extract_archive +from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.os import clean_path, extract_archive, get_package_path, in_tempdir def _fetch_local(src: Path, destination: Path, config_override: Optional[dict] = None): diff --git a/src/ape_run/_cli.py b/src/ape_run/_cli.py index 26b77599d0..94e30d361d 100644 --- a/src/ape_run/_cli.py +++ b/src/ape_run/_cli.py @@ -10,11 +10,11 @@ import click from click import Command, Context, Option -from ape.cli import ConnectedProviderCommand, verbosity_option -from ape.cli.options import _VERBOSITY_VALUES, _create_verbosity_kwargs +from ape.cli.commands import ConnectedProviderCommand +from ape.cli.options import _VERBOSITY_VALUES, _create_verbosity_kwargs, verbosity_option from ape.exceptions import ApeException, handle_ape_exception from ape.logging import logger -from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.basemodel import ManagerAccessMixin as access from ape.utils.os import get_relative_path, use_temp_sys_path from ape_console._cli import console @@ -60,7 +60,7 @@ def run_script_module(script_path: Path): return run_module(mod_name) -class ScriptCommand(click.MultiCommand, ManagerAccessMixin): +class ScriptCommand(click.MultiCommand): def __init__(self, *args, **kwargs): if "result_callback" not in kwargs: kwargs["result_callback"] = self.result_callback @@ -78,9 +78,9 @@ def invoke(self, ctx: Context) -> Any: # Print the exception trace and then launch the console # Attempt to use source-traceback style printing. network_value = ( - ctx.params.get("network") or self.network_manager.default_ecosystem.name + ctx.params.get("network") or access.network_manager.default_ecosystem.name ) - with self.network_manager.parse_network_choice( + with access.network_manager.parse_network_choice( network_value, disconnect_on_exit=False ): path = ctx.obj.local_project.path @@ -95,7 +95,7 @@ def invoke(self, ctx: Context) -> Any: raise def _get_command(self, filepath: Path) -> Union[click.Command, click.Group, None]: - relative_filepath = get_relative_path(filepath, ManagerAccessMixin.local_project.path) + relative_filepath = get_relative_path(filepath, access.local_project.path) # First load the code module by compiling it # NOTE: This does not execute the module @@ -175,10 +175,10 @@ def call(): @property def commands(self) -> dict[str, Union[click.Command, click.Group]]: - if not self.local_project.scripts_folder.is_dir(): + if not access.local_project.scripts_folder.is_dir(): return {} - return self._get_cli_commands(self.local_project.scripts_folder) + return self._get_cli_commands(access.local_project.scripts_folder) def _get_cli_commands(self, base_path: Path) -> dict: commands: dict[str, Command] = {} @@ -225,7 +225,7 @@ def result_callback(self, result, interactive: bool): # type: ignore[override] def _launch_console(self): trace = inspect.trace() trace_frames = [ - x for x in trace if x.filename.startswith(str(self.local_project.scripts_folder)) + x for x in trace if x.filename.startswith(str(access.local_project.scripts_folder)) ] if not trace_frames: # Error from Ape internals; avoid launching console. @@ -247,7 +247,7 @@ def _launch_console(self): if frame: del frame - return console(project=self.local_project, extra_locals=extra_locals, embed=True) + return console(project=access.local_project, extra_locals=extra_locals, embed=True) @click.command( diff --git a/src/ape_test/__init__.py b/src/ape_test/__init__.py index 9a8a665338..29d9ac4cfb 100644 --- a/src/ape_test/__init__.py +++ b/src/ape_test/__init__.py @@ -1,183 +1,42 @@ -from typing import NewType, Optional, Union - -from pydantic import NonNegativeInt, field_validator +from importlib import import_module from ape import plugins -from ape.api.config import PluginConfig -from ape.utils.basemodel import ManagerAccessMixin -from ape.utils.testing import ( - DEFAULT_NUMBER_OF_TEST_ACCOUNTS, - DEFAULT_TEST_ACCOUNT_BALANCE, - DEFAULT_TEST_HD_PATH, - DEFAULT_TEST_MNEMONIC, -) -from ape_test.accounts import TestAccount, TestAccountContainer -from ape_test.provider import EthTesterProviderConfig, LocalProvider - - -class GasExclusion(PluginConfig): - contract_name: str = "*" # If only given method, searches across all contracts. - method_name: Optional[str] = None # By default, match all methods in a contract - - -CoverageExclusion = NewType("CoverageExclusion", GasExclusion) - - -class GasConfig(PluginConfig): - """ - Configuration related to test gas reports. - """ - - exclude: list[GasExclusion] = [] - """ - Contract methods patterns to skip. Specify ``contract_name:`` and not - ``method_name:`` to skip all methods in the contract. Only specify - ``method_name:`` to skip all methods across all contracts. Specify - both to skip methods in a certain contracts. Entries use glob-rules; - use ``prefix_*`` to skip all items with a certain prefix. - """ - - reports: list[str] = [] - """ - Report-types to use. Currently, only supports `terminal`. - """ - - @field_validator("reports", mode="before") - @classmethod - def validate_reports(cls, values): - values = list(set(values or [])) - valid = ("terminal",) - for val in values: - if val not in valid: - valid_str = ", ".join(valid) - raise ValueError(f"Invalid gas-report format '{val}'. Valid: {valid_str}") - - return values - - @property - def show(self) -> bool: - return "terminal" in self.reports - - -_ReportType = Union[bool, dict] -"""Dict is for extra report settings.""" - - -class CoverageReportsConfig(PluginConfig): - """ - Enable reports. - """ - - terminal: _ReportType = True - """ - Set to ``False`` to hide the terminal coverage report. - """ - - xml: _ReportType = False - """ - Set to ``True`` to generate an XML coverage report in your .build folder. - """ - - html: _ReportType = False - """ - Set to ``True`` to generate HTML coverage reports. - """ - - @property - def has_any(self) -> bool: - return any(x not in ({}, None, False) for x in (self.html, self.terminal, self.xml)) - - -class CoverageConfig(PluginConfig): - """ - Configuration related to contract coverage. - """ - - track: bool = False - """ - Setting this to ``True`` is the same as always running with - the ``--coverage`` flag. - """ - - reports: CoverageReportsConfig = CoverageReportsConfig() - """ - Enable reports. - """ - - exclude: list[CoverageExclusion] = [] - """ - Contract methods patterns to skip. Specify ``contract_name:`` and not - ``method_name:`` to skip all methods in the contract. Only specify - ``method_name:`` to skip all methods across all contracts. Specify - both to skip methods in a certain contracts. Entries use glob-rules; - use ``prefix_*`` to skip all items with a certain prefix. - """ - - -class ApeTestConfig(PluginConfig): - balance: int = DEFAULT_TEST_ACCOUNT_BALANCE - """ - The starting-balance of every test account in Wei (NOT Ether). - """ - - coverage: CoverageConfig = CoverageConfig() - """ - Configuration related to coverage reporting. - """ - - disconnect_providers_after: bool = True - """ - Set to ``False`` to keep providers connected at the end of the test run. - """ - - gas: GasConfig = GasConfig() - """ - Configuration related to gas reporting. - """ - - hd_path: str = DEFAULT_TEST_HD_PATH - """ - The hd_path to use when generating the test accounts. - """ - - mnemonic: str = DEFAULT_TEST_MNEMONIC - """ - The mnemonic to use when generating the test accounts. - """ - - number_of_accounts: NonNegativeInt = DEFAULT_NUMBER_OF_TEST_ACCOUNTS - """ - The number of test accounts to generate in the provider. - """ - - provider: EthTesterProviderConfig = EthTesterProviderConfig() - """ - Settings for the provider. - """ - - @field_validator("balance", mode="before") - @classmethod - def validate_balance(cls, value): - return ( - value - if isinstance(value, int) - else ManagerAccessMixin.conversion_manager.convert(value, int) - ) @plugins.register(plugins.Config) def config_class(): - return ApeTestConfig + module = import_module("ape_test.config") + return module.ApeTestConfig @plugins.register(plugins.AccountPlugin) def account_types(): - return TestAccountContainer, TestAccount + module = import_module("ape_test.accounts") + return module.TestAccountContainer, module.TestAccount @plugins.register(plugins.ProviderPlugin) def providers(): - yield "ethereum", "local", LocalProvider + module = import_module("ape_test.provider") + yield "ethereum", "local", module.LocalProvider + + +def __getattr__(name: str): + if name in ("TestAccountContainer", "TestAccount"): + module = import_module("ape_test.accounts") + elif name in ( + "EthTesterProviderConfig", + "GasExclusion", + "GasConfig", + "CoverageReportsConfig", + "CoverageConfig", + "ApeTestConfig", + ): + module = import_module("ape_test.config") + else: + module = import_module("ape_test.provider") + + return getattr(module, name) __all__ = [ diff --git a/src/ape_test/_cli.py b/src/ape_test/_cli.py index 97431e4976..22541d51d6 100644 --- a/src/ape_test/_cli.py +++ b/src/ape_test/_cli.py @@ -2,6 +2,7 @@ import threading import time from datetime import datetime, timedelta +from functools import cached_property from pathlib import Path from subprocess import run as run_subprocess from typing import Any @@ -12,9 +13,9 @@ from watchdog import events from watchdog.observers import Observer -from ape.cli import ape_cli_context +from ape.cli.options import ape_cli_context from ape.logging import LogLevel, _get_level -from ape.utils import ManagerAccessMixin, cached_property +from ape.utils.basemodel import ManagerAccessMixin as access # Copied from https://github.com/olzhasar/pytest-watcher/blob/master/pytest_watcher/watcher.py trigger_lock = threading.Lock() @@ -32,7 +33,7 @@ def emit_trigger(): trigger = datetime.now() -class EventHandler(events.FileSystemEventHandler, ManagerAccessMixin): +class EventHandler(events.FileSystemEventHandler): EVENTS_WATCHED = ( events.EVENT_TYPE_CREATED, events.EVENT_TYPE_DELETED, @@ -46,7 +47,7 @@ def dispatch(self, event: events.FileSystemEvent) -> None: @cached_property def _extensions_to_watch(self) -> list[str]: - return [".py", *self.compiler_manager.registered_compilers.keys()] + return [".py", *access.compiler_manager.registered_compilers.keys()] def _is_path_watched(self, filepath: str) -> bool: """ diff --git a/src/ape_test/config.py b/src/ape_test/config.py new file mode 100644 index 0000000000..ee915d40bf --- /dev/null +++ b/src/ape_test/config.py @@ -0,0 +1,168 @@ +from typing import NewType, Optional, Union + +from pydantic import NonNegativeInt, field_validator + +from ape.api.config import PluginConfig +from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.testing import ( + DEFAULT_NUMBER_OF_TEST_ACCOUNTS, + DEFAULT_TEST_ACCOUNT_BALANCE, + DEFAULT_TEST_CHAIN_ID, + DEFAULT_TEST_HD_PATH, + DEFAULT_TEST_MNEMONIC, +) + + +class EthTesterProviderConfig(PluginConfig): + chain_id: int = DEFAULT_TEST_CHAIN_ID + auto_mine: bool = True + + +class GasExclusion(PluginConfig): + contract_name: str = "*" # If only given method, searches across all contracts. + method_name: Optional[str] = None # By default, match all methods in a contract + + +CoverageExclusion = NewType("CoverageExclusion", GasExclusion) + + +class GasConfig(PluginConfig): + """ + Configuration related to test gas reports. + """ + + exclude: list[GasExclusion] = [] + """ + Contract methods patterns to skip. Specify ``contract_name:`` and not + ``method_name:`` to skip all methods in the contract. Only specify + ``method_name:`` to skip all methods across all contracts. Specify + both to skip methods in a certain contracts. Entries use glob-rules; + use ``prefix_*`` to skip all items with a certain prefix. + """ + + reports: list[str] = [] + """ + Report-types to use. Currently, only supports `terminal`. + """ + + @field_validator("reports", mode="before") + @classmethod + def validate_reports(cls, values): + values = list(set(values or [])) + valid = ("terminal",) + for val in values: + if val not in valid: + valid_str = ", ".join(valid) + raise ValueError(f"Invalid gas-report format '{val}'. Valid: {valid_str}") + + return values + + @property + def show(self) -> bool: + return "terminal" in self.reports + + +_ReportType = Union[bool, dict] +"""Dict is for extra report settings.""" + + +class CoverageReportsConfig(PluginConfig): + """ + Enable reports. + """ + + terminal: _ReportType = True + """ + Set to ``False`` to hide the terminal coverage report. + """ + + xml: _ReportType = False + """ + Set to ``True`` to generate an XML coverage report in your .build folder. + """ + + html: _ReportType = False + """ + Set to ``True`` to generate HTML coverage reports. + """ + + @property + def has_any(self) -> bool: + return any(x not in ({}, None, False) for x in (self.html, self.terminal, self.xml)) + + +class CoverageConfig(PluginConfig): + """ + Configuration related to contract coverage. + """ + + track: bool = False + """ + Setting this to ``True`` is the same as always running with + the ``--coverage`` flag. + """ + + reports: CoverageReportsConfig = CoverageReportsConfig() + """ + Enable reports. + """ + + exclude: list[CoverageExclusion] = [] + """ + Contract methods patterns to skip. Specify ``contract_name:`` and not + ``method_name:`` to skip all methods in the contract. Only specify + ``method_name:`` to skip all methods across all contracts. Specify + both to skip methods in a certain contracts. Entries use glob-rules; + use ``prefix_*`` to skip all items with a certain prefix. + """ + + +class ApeTestConfig(PluginConfig): + balance: int = DEFAULT_TEST_ACCOUNT_BALANCE + """ + The starting-balance of every test account in Wei (NOT Ether). + """ + + coverage: CoverageConfig = CoverageConfig() + """ + Configuration related to coverage reporting. + """ + + disconnect_providers_after: bool = True + """ + Set to ``False`` to keep providers connected at the end of the test run. + """ + + gas: GasConfig = GasConfig() + """ + Configuration related to gas reporting. + """ + + hd_path: str = DEFAULT_TEST_HD_PATH + """ + The hd_path to use when generating the test accounts. + """ + + mnemonic: str = DEFAULT_TEST_MNEMONIC + """ + The mnemonic to use when generating the test accounts. + """ + + number_of_accounts: NonNegativeInt = DEFAULT_NUMBER_OF_TEST_ACCOUNTS + """ + The number of test accounts to generate in the provider. + """ + + provider: EthTesterProviderConfig = EthTesterProviderConfig() + """ + Settings for the provider. + """ + + @field_validator("balance", mode="before") + @classmethod + def validate_balance(cls, value): + return ( + value + if isinstance(value, int) + else ManagerAccessMixin.conversion_manager.convert(value, int) + ) diff --git a/src/ape_test/provider.py b/src/ape_test/provider.py index 4f9800ac02..afd12e227a 100644 --- a/src/ape_test/provider.py +++ b/src/ape_test/provider.py @@ -17,7 +17,6 @@ from web3.providers.eth_tester.defaults import API_ENDPOINTS, static_return from web3.types import TxParams -from ape.api.config import PluginConfig from ape.api.providers import BlockAPI, TestProviderAPI from ape.api.trace import TraceAPI from ape.api.transactions import ReceiptAPI, TransactionAPI @@ -35,19 +34,15 @@ from ape.types.events import ContractLog, LogFilter from ape.types.vm import BlockID, SnapshotID from ape.utils.misc import gas_estimation_error_message -from ape.utils.testing import DEFAULT_TEST_CHAIN_ID, DEFAULT_TEST_HD_PATH +from ape.utils.testing import DEFAULT_TEST_HD_PATH from ape_ethereum.provider import Web3Provider from ape_ethereum.trace import TraceApproach, TransactionTrace +from ape_test.config import EthTesterProviderConfig if TYPE_CHECKING: from ape.api.accounts import TestAccountAPI -class EthTesterProviderConfig(PluginConfig): - chain_id: int = DEFAULT_TEST_CHAIN_ID - auto_mine: bool = True - - class LocalProvider(TestProviderAPI, Web3Provider): _evm_backend: Optional[PyEVMBackend] = None _CANNOT_AFFORD_GAS_PATTERN: Pattern = re.compile(