From 46a1d2cfbee44bba1bc1002bf5757bca80d90fb4 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 29 Oct 2024 12:35:46 -0500 Subject: [PATCH] perf: more things that make `ape --help` way faster (#2351) --- src/ape/_cli.py | 33 ++++---- src/ape/cli/__init__.py | 2 + src/ape/cli/arguments.py | 21 +++-- src/ape/cli/choices.py | 57 +++++++++++--- src/ape/cli/commands.py | 3 +- src/ape/cli/options.py | 27 ++++--- src/ape/exceptions.py | 15 ++-- src/ape/plugins/account.py | 7 +- src/ape/plugins/compiler.py | 9 ++- src/ape/plugins/config.py | 7 +- src/ape/plugins/converter.py | 8 +- src/ape/plugins/network.py | 22 ++++-- src/ape/plugins/project.py | 10 ++- src/ape/utils/testing.py | 10 ++- src/ape_accounts/__init__.py | 19 ++--- src/ape_accounts/_cli.py | 31 ++++++-- src/ape_cache/__init__.py | 22 +++--- src/ape_cache/config.py | 5 ++ src/ape_compile/__init__.py | 101 ++++--------------------- src/ape_compile/_cli.py | 10 ++- src/ape_compile/config.py | 89 ++++++++++++++++++++++ src/ape_console/__init__.py | 7 +- src/ape_console/_cli.py | 10 ++- src/ape_networks/__init__.py | 48 ++++-------- src/ape_networks/_cli.py | 22 +++--- src/ape_networks/config.py | 38 ++++++++++ src/ape_plugins/__init__.py | 7 +- tests/functional/test_compilers.py | 2 +- tests/integration/cli/test_accounts.py | 2 +- 29 files changed, 398 insertions(+), 246 deletions(-) create mode 100644 src/ape_cache/config.py create mode 100644 src/ape_compile/config.py create mode 100644 src/ape_networks/config.py diff --git a/src/ape/_cli.py b/src/ape/_cli.py index 4781af0a7a..caf223ce0a 100644 --- a/src/ape/_cli.py +++ b/src/ape/_cli.py @@ -1,13 +1,14 @@ import difflib import re import sys -import warnings from collections.abc import Iterable +from functools import cached_property from gettext import gettext from importlib import import_module from importlib.metadata import entry_points from pathlib import Path from typing import Any, Optional +from warnings import catch_warnings, simplefilter import click import rich @@ -17,7 +18,6 @@ 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.utils.basemodel import ManagerAccessMixin as access _DIFFLIB_CUT_OFF = 0.6 @@ -27,6 +27,8 @@ def display_config(ctx, param, value): if not value or ctx.resilient_parsing: return + from ape.utils.basemodel import ManagerAccessMixin as access + click.echo("# Current configuration") # NOTE: Using json-mode as yaml.dump requires JSON-like structure. @@ -37,6 +39,8 @@ def display_config(ctx, param, value): def _validate_config(): + from ape.utils.basemodel import ManagerAccessMixin as access + project = access.local_project try: _ = project.config @@ -47,7 +51,6 @@ def _validate_config(): class ApeCLI(click.MultiCommand): - _commands: Optional[dict] = None _CLI_GROUP_NAME = "ape_cli_subcommands" def parse_args(self, ctx: Context, args: list[str]) -> list[str]: @@ -60,6 +63,8 @@ def parse_args(self, ctx: Context, args: list[str]) -> list[str]: return super().parse_args(ctx, args) def format_commands(self, ctx, formatter) -> None: + from ape.utils.basemodel import ManagerAccessMixin as access + commands = [] for subcommand in self.list_commands(ctx): cmd = self.get_command(ctx, subcommand) @@ -142,25 +147,21 @@ def _suggest_cmd(usage_error): raise usage_error - @property + @cached_property def commands(self) -> dict: - if self._commands: - return self._commands - _entry_points = entry_points() eps: Iterable - if select_fn := getattr(_entry_points, "select", None): - # NOTE: Using getattr because mypy. - eps = select_fn(group=self._CLI_GROUP_NAME) - else: - # Python 3.9. Can remove once we drop support. - with warnings.catch_warnings(): - warnings.simplefilter("ignore") + + try: + eps = _entry_points.select(group=self._CLI_GROUP_NAME) + except AttributeError: + # Fallback for Python 3.9 + with catch_warnings(): + simplefilter("ignore") eps = _entry_points.get(self._CLI_GROUP_NAME, []) # type: ignore 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 + return dict(sorted(commands.items())) def list_commands(self, ctx) -> list[str]: return [k for k in self.commands] diff --git a/src/ape/cli/__init__.py b/src/ape/cli/__init__.py index 4e0db3e853..262712c414 100644 --- a/src/ape/cli/__init__.py +++ b/src/ape/cli/__init__.py @@ -6,6 +6,7 @@ from ape.cli.choices import ( AccountAliasPromptChoice, Alias, + LazyChoice, NetworkChoice, OutputFormat, PromptChoice, @@ -42,6 +43,7 @@ "existing_alias_argument", "incompatible_with", "JSON", + "LazyChoice", "network_option", "NetworkChoice", "NetworkOption", diff --git a/src/ape/cli/arguments.py b/src/ape/cli/arguments.py index 75a09dc4d7..71701ad1a9 100644 --- a/src/ape/cli/arguments.py +++ b/src/ape/cli/arguments.py @@ -7,15 +7,14 @@ from ape.cli.choices import _ACCOUNT_TYPE_FILTER, Alias from ape.logging import logger -from ape.utils.basemodel import ManagerAccessMixin -from ape.utils.os import get_full_extension -from ape.utils.validators import _validate_account_alias if TYPE_CHECKING: from ape.managers.project import ProjectManager def _alias_callback(ctx, param, value): + from ape.utils.validators import _validate_account_alias + return _validate_account_alias(value) @@ -28,7 +27,6 @@ def existing_alias_argument(account_type: _ACCOUNT_TYPE_FILTER = None, **kwargs) If given, limits the type of account the user may choose from. **kwargs: click.argument overrides. """ - type_ = kwargs.pop("type", Alias(key=account_type)) return click.argument("alias", type=type_, **kwargs) @@ -45,12 +43,14 @@ def non_existing_alias_argument(**kwargs): return click.argument("alias", callback=callback, **kwargs) -class _ContractPaths(ManagerAccessMixin): +class _ContractPaths: """ Helper callback class for handling CLI-given contract paths. """ def __init__(self, value, project: Optional["ProjectManager"] = None): + from ape.utils.basemodel import ManagerAccessMixin + self.value = value self.missing_compilers: set[str] = set() # set of .ext self.project = project or ManagerAccessMixin.local_project @@ -105,14 +105,21 @@ def filtered_paths(self) -> set[Path]: @property def exclude_patterns(self) -> set[str]: - return self.config_manager.get_config("compile").exclude or set() + from ape.utils.basemodel import ManagerAccessMixin as access + + return access.config_manager.get_config("compile").exclude or set() def do_exclude(self, path: Union[Path, str]) -> bool: return self.project.sources.is_excluded(path) def compiler_is_unknown(self, path: Union[Path, str]) -> bool: + from ape.utils.basemodel import ManagerAccessMixin + from ape.utils.os import get_full_extension + ext = get_full_extension(path) - unknown_compiler = ext and ext not in self.compiler_manager.registered_compilers + unknown_compiler = ( + ext and ext not in ManagerAccessMixin.compiler_manager.registered_compilers + ) if unknown_compiler and ext not in self.missing_compilers: self.missing_compilers.add(ext) diff --git a/src/ape/cli/choices.py b/src/ape/cli/choices.py index 17a8bec427..e7dc752107 100644 --- a/src/ape/cli/choices.py +++ b/src/ape/cli/choices.py @@ -14,7 +14,6 @@ NetworkNotFoundError, ProviderNotFoundError, ) -from ape.utils.basemodel import ManagerAccessMixin as access if TYPE_CHECKING: from ape.api.accounts import AccountAPI @@ -26,6 +25,8 @@ def _get_accounts(key: _ACCOUNT_TYPE_FILTER) -> list["AccountAPI"]: + from ape.utils.basemodel import ManagerAccessMixin as access + accounts = access.account_manager add_test_accounts = False @@ -68,8 +69,11 @@ def __init__(self, key: _ACCOUNT_TYPE_FILTER = None): # NOTE: we purposely skip the constructor of `Choice` self.case_sensitive = False self._key_filter = key + + @cached_property + def choices(self) -> Sequence: # type: ignore[override] module = import_module("ape.types.basic") - self.choices = module._LazySequence(self._choices_iterator) + return module._LazySequence(self._choices_iterator) @property def _choices_iterator(self) -> Iterator[str]: @@ -206,6 +210,8 @@ def convert( else: alias = value + from ape.utils.basemodel import ManagerAccessMixin as access + accounts = access.account_manager if isinstance(alias, str) and alias.upper().startswith("TEST::"): idx_str = alias.upper().replace("TEST::", "") @@ -235,6 +241,8 @@ def print_choices(self): click.echo(f"{idx}. {choice}") did_print = True + from ape.utils.basemodel import ManagerAccessMixin as access + accounts = access.account_manager len_test_accounts = len(accounts.test_accounts) - 1 if len_test_accounts > 0: @@ -261,6 +269,7 @@ def select_account(self) -> "AccountAPI": Returns: :class:`~ape.api.accounts.AccountAPI` """ + from ape.utils.basemodel import ManagerAccessMixin as access accounts = access.account_manager if not self.choices or len(self.choices) == 0: @@ -348,12 +357,7 @@ def __init__( base_type: Optional[type] = None, callback: Optional[Callable] = None, ): - 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._base_type = base_type self.callback = callback self.case_sensitive = case_sensitive self.ecosystem = ecosystem @@ -361,6 +365,21 @@ def __init__( self.provider = provider # NOTE: Purposely avoid super().init for performance reasons. + @property + def base_type(self) -> type["ProviderAPI"]: + # perf: property exists to delay import ProviderAPI at init time. + from ape.api.providers import ProviderAPI + + if self._base_type is not None: + return self._base_type + + self._base_type = ProviderAPI + return ProviderAPI + + @base_type.setter + def base_type(self, value): + self._base_type = value + @cached_property def choices(self) -> Sequence[Any]: # type: ignore[override] return get_networks(ecosystem=self.ecosystem, network=self.network, provider=self.provider) @@ -369,6 +388,8 @@ def get_metavar(self, param): return "[ecosystem-name][:[network-name][:[provider-name]]]" def convert(self, value: Any, param: Optional[Parameter], ctx: Optional[Context]) -> Any: + from ape.utils.basemodel import ManagerAccessMixin as access + choice: Optional[Union[str, "ProviderAPI"]] networks = access.network_manager if not value: @@ -406,8 +427,9 @@ def convert(self, value: Any, param: Optional[Parameter], ctx: Optional[Context] ) from err 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): + from ape.api.providers import ProviderAPI + + if issubclass(self.base_type, ProviderAPI): # Return the provider. choice = networks.get_provider_from_choice(network_choice=value) @@ -454,3 +476,18 @@ def output_format_choice(options: Optional[list[OutputFormat]] = None) -> Choice # Uses `str` form of enum for CLI choices. return click.Choice([o.value for o in options], case_sensitive=False) + + +class LazyChoice(Choice): + """ + A simple lazy-choice where choices are evaluated lazily. + """ + + def __init__(self, get_choices: Callable[[], Sequence[str]], case_sensitive: bool = False): + self._get_choices = get_choices + self.case_sensitive = case_sensitive + # Note: Purposely avoid super init. + + @cached_property + def choices(self) -> Sequence[str]: # type: ignore[override] + return self._get_choices() diff --git a/src/ape/cli/commands.py b/src/ape/cli/commands.py index 00610d2792..fb4d305363 100644 --- a/src/ape/cli/commands.py +++ b/src/ape/cli/commands.py @@ -7,7 +7,6 @@ from ape.cli.choices import _NONE_NETWORK, NetworkChoice from ape.exceptions import NetworkError -from ape.utils.basemodel import ManagerAccessMixin as access if TYPE_CHECKING: from ape.api.networks import ProviderContextManager @@ -26,6 +25,8 @@ def get_param_from_ctx(ctx: Context, param: str) -> Optional[Any]: def parse_network(ctx: Context) -> Optional["ProviderContextManager"]: + from ape.utils.basemodel import ManagerAccessMixin as access + interactive = get_param_from_ctx(ctx, "interactive") # Handle if already parsed (as when using network-option) diff --git a/src/ape/cli/options.py b/src/ape/cli/options.py index 60d1da3b26..508051e13c 100644 --- a/src/ape/cli/options.py +++ b/src/ape/cli/options.py @@ -3,11 +3,10 @@ from functools import partial from importlib import import_module from pathlib import Path -from typing import Any, NoReturn, Optional, Union +from typing import TYPE_CHECKING, Any, NoReturn, Optional, Union import click from click import Option -from ethpm_types import ContractType from ape.cli.choices import ( _ACCOUNT_TYPE_FILTER, @@ -21,12 +20,14 @@ from ape.cli.paramtype import JSON, Noop from ape.exceptions import Abort, ProjectError from ape.logging import DEFAULT_LOG_LEVEL, ApeLogger, LogLevel, logger -from ape.utils.basemodel import ManagerAccessMixin + +if TYPE_CHECKING: + from ethpm_types.contract_type import ContractType _VERBOSITY_VALUES = ("--verbosity", "-v") -class ApeCliContextObject(ManagerAccessMixin, dict): +class ApeCliContextObject(dict): """ A ``click`` context object class. Use via :meth:`~ape.cli.options.ape_cli_context()`. It provides common CLI utilities for ape, such as logging or @@ -45,6 +46,8 @@ def __getattr__(self, item: str) -> Any: try: return self.__getattribute__(item) except AttributeError: + from ape.utils.basemodel import ManagerAccessMixin + return getattr(ManagerAccessMixin, item) @staticmethod @@ -174,14 +177,12 @@ def __init__(self, *args, **kwargs) -> None: provider = kwargs.pop("provider", None) default = kwargs.pop("default", "auto") - 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 # because parsing happens earlier to handle advanced usage. if not kwargs.get("type"): + base_type = kwargs.pop("base_type", None) kwargs["type"] = NetworkChoice( case_sensitive=False, ecosystem=ecosystem, @@ -204,6 +205,8 @@ def __init__(self, *args, **kwargs) -> None: else: # NOTE: Use a function as the default so it is calculated lazily def fn(): + from ape.utils.basemodel import ManagerAccessMixin + return ManagerAccessMixin.network_manager.default_ecosystem.name default = fn @@ -344,6 +347,8 @@ def _update_context_with_network(ctx, provider, requested_network_objects): def _get_provider(value, default, keep_as_choice_str): + from ape.utils.basemodel import ManagerAccessMixin + use_default = value is None and default == "auto" provider_module = import_module("ape.api.providers") ProviderAPI = provider_module.ProviderAPI @@ -431,10 +436,12 @@ def account_option(account_type: _ACCOUNT_TYPE_FILTER = None) -> Callable: ) -def _load_contracts(ctx, param, value) -> Optional[Union[ContractType, list[ContractType]]]: +def _load_contracts(ctx, param, value) -> Optional[Union["ContractType", list["ContractType"]]]: if not value: return None + from ape.utils.basemodel import ManagerAccessMixin + if len(ManagerAccessMixin.local_project.contracts) == 0: raise ProjectError("Project has no contracts.") @@ -442,7 +449,7 @@ def _load_contracts(ctx, param, value) -> Optional[Union[ContractType, list[Cont # and therefore we should also return a list. is_multiple = isinstance(value, (tuple, list)) - def get_contract(contract_name: str) -> ContractType: + def get_contract(contract_name: str) -> "ContractType": if contract_name not in ManagerAccessMixin.local_project.contracts: raise ProjectError(f"No contract named '{value}'") @@ -523,6 +530,8 @@ def handle_parse_result(self, ctx, opts, args): def _project_callback(ctx, param, val): + from ape.utils.basemodel import ManagerAccessMixin + pm = None if not val: pm = ManagerAccessMixin.local_project diff --git a/src/ape/exceptions.py b/src/ape/exceptions.py index 19ab04e988..db50583303 100644 --- a/src/ape/exceptions.py +++ b/src/ape/exceptions.py @@ -14,13 +14,14 @@ import click from eth_typing import Hash32, HexStr from eth_utils import humanize_hash, to_hex -from ethpm_types import ContractType -from ethpm_types.abi import ConstructorABI, ErrorABI, MethodABI from rich import print as rich_print from ape.logging import LogLevel, logger if TYPE_CHECKING: + from ethpm_types.abi import ConstructorABI, ErrorABI, MethodABI + from ethpm_types.contract_type import ContractType + from ape.api.networks import NetworkAPI from ape.api.providers import SubprocessProvider from ape.api.trace import TraceAPI @@ -90,7 +91,7 @@ class MissingDeploymentBytecodeError(ContractDataError): Raised when trying to deploy an interface or empty data. """ - def __init__(self, contract_type: ContractType): + def __init__(self, contract_type: "ContractType"): message = "Cannot deploy: contract" if name := contract_type.name: message = f"{message} '{name}'" @@ -109,7 +110,7 @@ class ArgumentsLengthError(ContractDataError): def __init__( self, arguments_length: int, - inputs: Union[MethodABI, ConstructorABI, int, list, None] = None, + inputs: Union["MethodABI", "ConstructorABI", int, list, None] = None, **kwargs, ): prefix = ( @@ -120,7 +121,7 @@ def __init__( super().__init__(f"{prefix}.") return - inputs_ls: list[Union[MethodABI, ConstructorABI, int]] = ( + inputs_ls: list[Union["MethodABI", "ConstructorABI", int]] = ( inputs if isinstance(inputs, list) else [inputs] ) if not inputs_ls: @@ -223,7 +224,7 @@ def address(self) -> Optional["AddressType"]: return receiver @cached_property - def contract_type(self) -> Optional[ContractType]: + def contract_type(self) -> Optional["ContractType"]: if not (address := self.address): # Contract address not found. return None @@ -849,7 +850,7 @@ class CustomError(ContractLogicError): def __init__( self, - abi: ErrorABI, + abi: "ErrorABI", inputs: dict[str, Any], txn: Optional[FailedTxn] = None, trace: _TRACE_ARG = None, diff --git a/src/ape/plugins/account.py b/src/ape/plugins/account.py index 0598ca625d..f3e78ae3f2 100644 --- a/src/ape/plugins/account.py +++ b/src/ape/plugins/account.py @@ -1,7 +1,10 @@ -from ape.api.accounts import AccountAPI, AccountContainerAPI +from typing import TYPE_CHECKING from .pluggy_patch import PluginType, hookspec +if TYPE_CHECKING: + from ape.api.accounts import AccountAPI, AccountContainerAPI + class AccountPlugin(PluginType): """ @@ -13,7 +16,7 @@ class AccountPlugin(PluginType): @hookspec def account_types( # type: ignore[empty-body] self, - ) -> tuple[type[AccountContainerAPI], type[AccountAPI]]: + ) -> tuple[type["AccountContainerAPI"], type["AccountAPI"]]: """ A hook for returning a tuple of an account container and an account type. Each account-base plugin defines and returns their own types here. diff --git a/src/ape/plugins/compiler.py b/src/ape/plugins/compiler.py index 650e8bee3f..7fbf151557 100644 --- a/src/ape/plugins/compiler.py +++ b/src/ape/plugins/compiler.py @@ -1,7 +1,10 @@ -from ape.api.compiler import CompilerAPI +from typing import TYPE_CHECKING from .pluggy_patch import PluginType, hookspec +if TYPE_CHECKING: + from ape.api.compiler import CompilerAPI + class CompilerPlugin(PluginType): """ @@ -11,7 +14,9 @@ class CompilerPlugin(PluginType): """ @hookspec - def register_compiler(self) -> tuple[tuple[str], type[CompilerAPI]]: # type: ignore[empty-body] + def register_compiler( # type: ignore[empty-body] + self, + ) -> tuple[tuple[str], type["CompilerAPI"]]: """ A hook for returning the set of file extensions the plugin handles and the compiler class that can be used to compile them. diff --git a/src/ape/plugins/config.py b/src/ape/plugins/config.py index 933d407729..338b74c1f1 100644 --- a/src/ape/plugins/config.py +++ b/src/ape/plugins/config.py @@ -1,7 +1,10 @@ -from ape.api.config import PluginConfig +from typing import TYPE_CHECKING from .pluggy_patch import PluginType, hookspec +if TYPE_CHECKING: + from ape.api.config import PluginConfig + class Config(PluginType): """ @@ -12,7 +15,7 @@ class Config(PluginType): """ @hookspec - def config_class(self) -> type[PluginConfig]: # type: ignore[empty-body] + def config_class(self) -> type["PluginConfig"]: # type: ignore[empty-body] """ A hook that returns a :class:`~ape.api.config.PluginConfig` parser class that can be used to deconstruct the user config options for this plugins. diff --git a/src/ape/plugins/converter.py b/src/ape/plugins/converter.py index ac2a01232d..8f6c02e513 100644 --- a/src/ape/plugins/converter.py +++ b/src/ape/plugins/converter.py @@ -1,9 +1,11 @@ from collections.abc import Iterator - -from ape.api.convert import ConverterAPI +from typing import TYPE_CHECKING from .pluggy_patch import PluginType, hookspec +if TYPE_CHECKING: + from ape.api.convert import ConverterAPI + class ConversionPlugin(PluginType): """ @@ -12,7 +14,7 @@ class ConversionPlugin(PluginType): """ @hookspec - def converters(self) -> Iterator[tuple[str, type[ConverterAPI]]]: # type: ignore[empty-body] + def converters(self) -> Iterator[tuple[str, type["ConverterAPI"]]]: # type: ignore[empty-body] """ A hook that returns an iterator of tuples of a string ABI type and a ``ConverterAPI`` subclass. diff --git a/src/ape/plugins/network.py b/src/ape/plugins/network.py index 45aa93e8c2..dbd58a5cac 100644 --- a/src/ape/plugins/network.py +++ b/src/ape/plugins/network.py @@ -1,11 +1,13 @@ from collections.abc import Iterator - -from ape.api.explorers import ExplorerAPI -from ape.api.networks import EcosystemAPI, NetworkAPI -from ape.api.providers import ProviderAPI +from typing import TYPE_CHECKING from .pluggy_patch import PluginType, hookspec +if TYPE_CHECKING: + from ape.api.explorers import ExplorerAPI + from ape.api.networks import EcosystemAPI, NetworkAPI + from ape.api.providers import ProviderAPI + class EcosystemPlugin(PluginType): """ @@ -15,7 +17,7 @@ class EcosystemPlugin(PluginType): """ @hookspec # type: ignore[empty-body] - def ecosystems(self) -> Iterator[type[EcosystemAPI]]: + def ecosystems(self) -> Iterator[type["EcosystemAPI"]]: """ A hook that must return an iterator of :class:`ape.api.networks.EcosystemAPI` subclasses. @@ -39,7 +41,7 @@ class NetworkPlugin(PluginType): """ @hookspec # type: ignore[empty-body] - def networks(self) -> Iterator[tuple[str, str, type[NetworkAPI]]]: + def networks(self) -> Iterator[tuple[str, str, type["NetworkAPI"]]]: """ A hook that must return an iterator of tuples of: @@ -67,7 +69,9 @@ class ProviderPlugin(PluginType): """ @hookspec - def providers(self) -> Iterator[tuple[str, str, type[ProviderAPI]]]: # type: ignore[empty-body] + def providers( # type: ignore[empty-body] + self, + ) -> Iterator[tuple[str, str, type["ProviderAPI"]]]: """ A hook that must return an iterator of tuples of: @@ -93,7 +97,9 @@ class ExplorerPlugin(PluginType): """ @hookspec - def explorers(self) -> Iterator[tuple[str, str, type[ExplorerAPI]]]: # type: ignore[empty-body] + def explorers( # type: ignore[empty-body] + self, + ) -> Iterator[tuple[str, str, type["ExplorerAPI"]]]: """ A hook that must return an iterator of tuples of: diff --git a/src/ape/plugins/project.py b/src/ape/plugins/project.py index 32c14a4f54..5b4d44d820 100644 --- a/src/ape/plugins/project.py +++ b/src/ape/plugins/project.py @@ -1,9 +1,11 @@ from collections.abc import Iterator - -from ape.api.projects import DependencyAPI, ProjectAPI +from typing import TYPE_CHECKING from .pluggy_patch import PluginType, hookspec +if TYPE_CHECKING: + from ape.api.projects import DependencyAPI, ProjectAPI + class ProjectPlugin(PluginType): """ @@ -15,7 +17,7 @@ class ProjectPlugin(PluginType): """ @hookspec # type: ignore[empty-body] - def projects(self) -> Iterator[type[ProjectAPI]]: + def projects(self) -> Iterator[type["ProjectAPI"]]: """ A hook that returns a :class:`~ape.api.projects.ProjectAPI` subclass type. @@ -31,7 +33,7 @@ class DependencyPlugin(PluginType): """ @hookspec - def dependencies(self) -> dict[str, type[DependencyAPI]]: # type: ignore[empty-body] + def dependencies(self) -> dict[str, type["DependencyAPI"]]: # type: ignore[empty-body] """ A hook that returns a :class:`~ape.api.projects.DependencyAPI` mapped to its ``ape-config.yaml`` file dependencies special key. For example, diff --git a/src/ape/utils/testing.py b/src/ape/utils/testing.py index 6d85efed26..cc2ed7d3d4 100644 --- a/src/ape/utils/testing.py +++ b/src/ape/utils/testing.py @@ -1,8 +1,5 @@ from collections import namedtuple -from eth_account import Account -from eth_account.hdaccount import HDPath -from eth_account.hdaccount.mnemonic import Mnemonic from eth_utils import to_hex DEFAULT_NUMBER_OF_TEST_ACCOUNTS = 10 @@ -47,6 +44,9 @@ def generate_dev_accounts( Returns: list[:class:`~ape.utils.GeneratedDevAccount`]: List of development accounts. """ + # perf: lazy imports so module loads faster. + from eth_account.hdaccount.mnemonic import Mnemonic + seed = Mnemonic.to_seed(mnemonic) hd_path_format = ( hd_path if "{}" in hd_path or "{0}" in hd_path else f"{hd_path.rstrip('/')}/{{}}" @@ -58,6 +58,10 @@ def generate_dev_accounts( def _generate_dev_account(hd_path, index: int, seed: bytes) -> GeneratedDevAccount: + # perf: lazy imports so module loads faster. + from eth_account.account import Account + from eth_account.hdaccount import HDPath + return GeneratedDevAccount( address=Account.from_key( private_key := to_hex(HDPath(hd_path.format(index)).derive(seed)) diff --git a/src/ape_accounts/__init__.py b/src/ape_accounts/__init__.py index 862b78819b..b3af3607f3 100644 --- a/src/ape_accounts/__init__.py +++ b/src/ape_accounts/__init__.py @@ -1,19 +1,20 @@ -from ape import plugins +from importlib import import_module +from typing import Any -from .accounts import ( - AccountContainer, - KeyfileAccount, - generate_account, - import_account_from_mnemonic, - import_account_from_private_key, -) +from ape.plugins import AccountPlugin, register -@plugins.register(plugins.AccountPlugin) +@register(AccountPlugin) def account_types(): + from ape_accounts.accounts import AccountContainer, KeyfileAccount + return AccountContainer, KeyfileAccount +def __getattr__(name: str) -> Any: + return getattr(import_module("ape_accounts.accounts"), name) + + __all__ = [ "AccountContainer", "KeyfileAccount", diff --git a/src/ape_accounts/_cli.py b/src/ape_accounts/_cli.py index 27fd75cb67..f6708a0417 100644 --- a/src/ape_accounts/_cli.py +++ b/src/ape_accounts/_cli.py @@ -3,21 +3,23 @@ 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.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 as access if TYPE_CHECKING: from ape.api.accounts import AccountAPI from ape_accounts.accounts import AccountContainer, KeyfileAccount +ETHEREUM_DEFAULT_PATH = "m/44'/60'/0'/0/0" + + def _get_container() -> "AccountContainer": + from ape.utils.basemodel import ManagerAccessMixin as access + # NOTE: Must used the instantiated version of `AccountsContainer` in `accounts` return access.account_manager.containers["accounts"] @@ -144,15 +146,14 @@ def ask_for_passphrase(): confirmation_prompt=True, ) - account_module = import_module("ape_accounts.accounts") if import_from_mnemonic: + from eth_account import Account as EthAccount + mnemonic = click.prompt("Enter mnemonic seed phrase", hide_input=True) EthAccount.enable_unaudited_hdwallet_features() try: passphrase = ask_for_passphrase() - account = account_module.import_account_from_mnemonic( - alias, passphrase, mnemonic, custom_hd_path - ) + account = _account_from_mnemonic(alias, passphrase, mnemonic, hd_path=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}") @@ -161,7 +162,7 @@ def ask_for_passphrase(): key = click.prompt("Enter Private Key", hide_input=True) try: passphrase = ask_for_passphrase() - account = account_module.import_account_from_private_key(alias, passphrase, key) + account = _account_from_key(alias, passphrase, key) except Exception as error: cli_ctx.abort(f"Key can't be imported: {error}") @@ -176,10 +177,24 @@ def _load_account_type(account: "AccountAPI") -> bool: return isinstance(account, module.KeyfileAccount) +def _account_from_mnemonic( + alias: str, passphrase: str, mnemonic: str, hd_path: str = ETHEREUM_DEFAULT_PATH +) -> "KeyfileAccount": + account_module = import_module("ape_accounts.accounts") + return account_module.import_account_from_mnemonic(alias, passphrase, mnemonic, hd_path=hd_path) + + +def _account_from_key(alias: str, passphrase: str, key: str) -> "KeyfileAccount": + account_module = import_module("ape_accounts.accounts") + return account_module.import_account_from_private_key(alias, passphrase, key) + + @cli.command(short_help="Export an account private key") @ape_cli_context() @existing_alias_argument(account_type=_load_account_type) def export(cli_ctx, alias): + from eth_account import Account as EthAccount + path = _get_container().data_folder.joinpath(f"{alias}.json") account = json.loads(path.read_text()) password = click.prompt("Enter password to decrypt account", hide_input=True) diff --git a/src/ape_cache/__init__.py b/src/ape_cache/__init__.py index 936e2ad8bb..516e7e6a91 100644 --- a/src/ape_cache/__init__.py +++ b/src/ape_cache/__init__.py @@ -1,19 +1,16 @@ from importlib import import_module -from ape import plugins -from ape.api.config import PluginConfig +from ape.plugins import Config, QueryPlugin, register -class CacheConfig(PluginConfig): - size: int = 1024**3 # 1gb - - -@plugins.register(plugins.Config) +@register(Config) def config_class(): + from ape_cache.config import CacheConfig + return CacheConfig -@plugins.register(plugins.QueryPlugin) +@register(QueryPlugin) def query_engines(): query = import_module("ape_cache.query") return query.CacheQueryProvider @@ -21,13 +18,18 @@ def query_engines(): def __getattr__(name): if name == "CacheQueryProvider": - query = import_module("ape_cache.query") - return query.CacheQueryProvider + module = import_module("ape_cache.query") + return module.CacheQueryProvider + + elif name == "CacheConfig": + module = import_module("ape_cache.config") + return module.CacheConfig else: raise AttributeError(name) __all__ = [ + "CacheConfig", "CacheQueryProvider", ] diff --git a/src/ape_cache/config.py b/src/ape_cache/config.py new file mode 100644 index 0000000000..264516b738 --- /dev/null +++ b/src/ape_cache/config.py @@ -0,0 +1,5 @@ +from ape.api.config import PluginConfig + + +class CacheConfig(PluginConfig): + size: int = 1024**3 # 1gb diff --git a/src/ape_compile/__init__.py b/src/ape_compile/__init__.py index f9cac3e8d6..30dad9fbd1 100644 --- a/src/ape_compile/__init__.py +++ b/src/ape_compile/__init__.py @@ -1,95 +1,26 @@ -import re -from re import Pattern -from typing import Union +from typing import Any -from pydantic import field_serializer, field_validator +from ape.plugins import Config as RConfig +from ape.plugins import register -from ape import plugins -from ape.api.config import ConfigEnum, PluginConfig -from ape.utils.misc import SOURCE_EXCLUDE_PATTERNS +@register(RConfig) +def config_class(): + from ape_compile.config import Config -class OutputExtras(ConfigEnum): - """ - Extra stuff you can output. It will - appear in ``.build/{key.lower()/`` - """ - - ABI = "ABI" - """ - Include this value to output the ABIs of your contracts - to minified JSONs. This is useful for hosting purposes - for web-apps. - """ - - -class Config(PluginConfig): - """ - Configure general compiler settings. - """ - - exclude: set[Union[str, Pattern]] = set() - """ - Source exclusion globs or regex patterns across all file types. - To use regex, start your values with ``r"`` and they'll be turned - into regex pattern objects. - - **NOTE**: ``ape.utils.misc.SOURCE_EXCLUDE_PATTERNS`` are automatically - included in this set. - """ - - include_dependencies: bool = False - """ - Set to ``True`` to compile dependencies during ``ape compile``. - Generally, dependencies are not compiled during ``ape compile`` - This is because dependencies may not compile in Ape on their own, - but you can still reference them in your project's contracts' imports. - Some projects may be more dependency-based and wish to have the - contract types always compiled during ``ape compile``, and these projects - should configure ``include_dependencies`` to be ``True``. - """ - - output_extra: list[OutputExtras] = [] - """ - Extra selections to output. Outputs to ``.build/{key.lower()}``. - """ - - @field_validator("exclude", mode="before") - @classmethod - def validate_exclude(cls, value): - given_values = [] - - # Convert regex to Patterns. - for given in value or []: - if (given.startswith('r"') and given.endswith('"')) or ( - given.startswith("r'") and given.endswith("'") - ): - value_clean = given[2:-1] - pattern = re.compile(value_clean) - given_values.append(pattern) + return Config - else: - given_values.append(given) - # Include defaults. - return {*given_values, *SOURCE_EXCLUDE_PATTERNS} +def __getattr__(name: str) -> Any: + if name == "Config": + from ape_compile.config import Config - @field_serializer("exclude", when_used="json") - def serialize_exclude(self, exclude, info): - """ - Exclude is put back with the weird r-prefix so we can - go to-and-from. - """ - result: list[str] = [] - for excl in exclude: - if isinstance(excl, Pattern): - result.append(f'r"{excl.pattern}"') - else: - result.append(excl) + return Config - return result + else: + raise AttributeError(name) -@plugins.register(plugins.Config) -def config_class(): - return Config +__all__ = [ + "Config", +] diff --git a/src/ape_compile/_cli.py b/src/ape_compile/_cli.py index 30b1425793..0251d77d3a 100644 --- a/src/ape_compile/_cli.py +++ b/src/ape_compile/_cli.py @@ -1,12 +1,14 @@ import sys from pathlib import Path +from typing import TYPE_CHECKING import click -from ethpm_types import ContractType from ape.cli.arguments import contract_file_paths_argument from ape.cli.options import ape_cli_context, config_override_option, project_option -from ape.utils.os import clean_path + +if TYPE_CHECKING: + from ethpm_types import ContractType def _include_dependencies_callback(ctx, param, value): @@ -93,6 +95,8 @@ def cli( _display_byte_code_sizes(cli_ctx, contract_types) if not compiled: + from ape.utils.os import clean_path # perf: lazy import + folder = clean_path(project.contracts_folder) cli_ctx.logger.warning(f"Nothing to compile ({folder}).") @@ -101,7 +105,7 @@ def cli( sys.exit(1) -def _display_byte_code_sizes(cli_ctx, contract_types: dict[str, ContractType]): +def _display_byte_code_sizes(cli_ctx, contract_types: dict[str, "ContractType"]): # Display bytecode size for *all* contract types (not just ones we compiled) code_size = [] for contract in contract_types.values(): diff --git a/src/ape_compile/config.py b/src/ape_compile/config.py new file mode 100644 index 0000000000..012e590b04 --- /dev/null +++ b/src/ape_compile/config.py @@ -0,0 +1,89 @@ +import re +from re import Pattern +from typing import Union + +from pydantic import field_serializer, field_validator + +from ape.api.config import ConfigEnum, PluginConfig +from ape.utils.misc import SOURCE_EXCLUDE_PATTERNS + + +class OutputExtras(ConfigEnum): + """ + Extra stuff you can output. It will + appear in ``.build/{key.lower()/`` + """ + + ABI = "ABI" + """ + Include this value to output the ABIs of your contracts + to minified JSONs. This is useful for hosting purposes + for web-apps. + """ + + +class Config(PluginConfig): + """ + Configure general compiler settings. + """ + + exclude: set[Union[str, Pattern]] = set() + """ + Source exclusion globs or regex patterns across all file types. + To use regex, start your values with ``r"`` and they'll be turned + into regex pattern objects. + + **NOTE**: ``ape.utils.misc.SOURCE_EXCLUDE_PATTERNS`` are automatically + included in this set. + """ + + include_dependencies: bool = False + """ + Set to ``True`` to compile dependencies during ``ape compile``. + Generally, dependencies are not compiled during ``ape compile`` + This is because dependencies may not compile in Ape on their own, + but you can still reference them in your project's contracts' imports. + Some projects may be more dependency-based and wish to have the + contract types always compiled during ``ape compile``, and these projects + should configure ``include_dependencies`` to be ``True``. + """ + + output_extra: list[OutputExtras] = [] + """ + Extra selections to output. Outputs to ``.build/{key.lower()}``. + """ + + @field_validator("exclude", mode="before") + @classmethod + def validate_exclude(cls, value): + given_values = [] + + # Convert regex to Patterns. + for given in value or []: + if (given.startswith('r"') and given.endswith('"')) or ( + given.startswith("r'") and given.endswith("'") + ): + value_clean = given[2:-1] + pattern = re.compile(value_clean) + given_values.append(pattern) + + else: + given_values.append(given) + + # Include defaults. + return {*given_values, *SOURCE_EXCLUDE_PATTERNS} + + @field_serializer("exclude", when_used="json") + def serialize_exclude(self, exclude, info): + """ + Exclude is put back with the weird r-prefix so we can + go to-and-from. + """ + result: list[str] = [] + for excl in exclude: + if isinstance(excl, Pattern): + result.append(f'r"{excl.pattern}"') + else: + result.append(excl) + + return result diff --git a/src/ape_console/__init__.py b/src/ape_console/__init__.py index 5bd99c7c67..b6ed602857 100644 --- a/src/ape_console/__init__.py +++ b/src/ape_console/__init__.py @@ -1,7 +1,8 @@ -from ape import plugins -from ape_console.config import ConsoleConfig +from ape.plugins import Config, register -@plugins.register(plugins.Config) +@register(Config) def config_class(): + from ape_console.config import ConsoleConfig + return ConsoleConfig diff --git a/src/ape_console/_cli.py b/src/ape_console/_cli.py index f04cdea48d..07855986d1 100644 --- a/src/ape_console/_cli.py +++ b/src/ape_console/_cli.py @@ -12,10 +12,6 @@ from ape.cli.commands import ConnectedProviderCommand from ape.cli.options import ape_cli_context, project_option -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 @@ -54,6 +50,8 @@ 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""" + from ape.utils.basemodel import ManagerAccessMixin as access + 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) @@ -102,6 +100,8 @@ def console( from IPython.terminal.ipapp import Config as IPythonConfig import ape + from ape.utils.misc import _python_version + from ape.version import version as ape_version project = project or ape.project banner = "" @@ -155,6 +155,8 @@ def console( def _launch_console(namespace: dict, ipy_config: "IPythonConfig", embed: bool, banner: str): import IPython + from ape_console.config import ConsoleConfig + ipython_kwargs = {"user_ns": namespace, "config": ipy_config} if embed: IPython.embed(**ipython_kwargs, colors="Neutral", banner1=banner) diff --git a/src/ape_networks/__init__.py b/src/ape_networks/__init__.py index f82dc72c30..51e382e1f8 100644 --- a/src/ape_networks/__init__.py +++ b/src/ape_networks/__init__.py @@ -1,44 +1,22 @@ -from typing import Optional +from importlib import import_module +from typing import Any -from ape import plugins -from ape.api.config import PluginConfig +from ape.plugins import Config, register -class CustomNetwork(PluginConfig): - """ - A custom network config. - """ - - name: str - """Name of the network e.g. mainnet.""" - - chain_id: int - """Chain ID (required).""" - - ecosystem: str - """The name of the ecosystem.""" - - base_ecosystem_plugin: Optional[str] = None - """The base ecosystem plugin to use, when applicable. Defaults to the default ecosystem.""" - - default_provider: str = "node" - """The default provider plugin to use. Default is the default node provider.""" +@register(Config) +def config_class(): + from ape_networks.config import NetworksConfig - request_header: dict = {} - """The HTTP request header.""" + return NetworksConfig - @property - def is_fork(self) -> bool: - """ - ``True`` when the name of the network ends in ``"-fork"``. - """ - return self.name.endswith("-fork") +def __getattr__(name: str) -> Any: + if name in ("NetworksConfig", "CustomNetwork"): + return getattr(import_module("ape_networks.config"), name) -class NetworksConfig(PluginConfig): - custom: list[CustomNetwork] = [] + else: + raise AttributeError(name) -@plugins.register(plugins.Config) -def config_class(): - return NetworksConfig +__all__ = ["NetworksConfig"] diff --git a/src/ape_networks/_cli.py b/src/ape_networks/_cli.py index fd9c019787..41ff326e42 100644 --- a/src/ape_networks/_cli.py +++ b/src/ape_networks/_cli.py @@ -1,5 +1,5 @@ import json -from collections.abc import Callable +from collections.abc import Callable, Sequence from importlib import import_module from typing import TYPE_CHECKING @@ -8,24 +8,22 @@ from rich import print as echo_rich_text from rich.tree import Tree -from ape.cli.choices import OutputFormat +from ape.cli.choices import LazyChoice, 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 as access if TYPE_CHECKING: from ape.api.providers import SubprocessProvider -def _filter_option(name: str, options): +def _filter_option(name: str, get_options: Callable[[], Sequence[str]]): return click.option( f"--{name}", f"{name}_filter", multiple=True, help=f"Filter the results by {name}", - type=click.Choice(options), + type=LazyChoice(get_options), ) @@ -36,20 +34,24 @@ def cli(): """ -def _lazy_get(name: str) -> _LazySequence: +def _lazy_get(name: str) -> Sequence: # NOTE: Using fn generator to maintain laziness. def gen(): + from ape.utils.basemodel import ManagerAccessMixin as access + yield from getattr(access.network_manager, f"{name}_names") + from ape.types.basic import _LazySequence + return _LazySequence(gen) @cli.command(name="list", short_help="List registered networks") @ape_cli_context() @output_format_option() -@_filter_option("ecosystem", _lazy_get("ecosystem")) -@_filter_option("network", _lazy_get("network")) -@_filter_option("provider", _lazy_get("provider")) +@_filter_option("ecosystem", lambda: _lazy_get("ecosystem")) +@_filter_option("network", lambda: _lazy_get("network")) +@_filter_option("provider", lambda: _lazy_get("provider")) def _list(cli_ctx, output_format, ecosystem_filter, network_filter, provider_filter): """ List all the registered ecosystems, networks, and providers. diff --git a/src/ape_networks/config.py b/src/ape_networks/config.py new file mode 100644 index 0000000000..381cd268b2 --- /dev/null +++ b/src/ape_networks/config.py @@ -0,0 +1,38 @@ +from typing import Optional + +from ape.api.config import PluginConfig + + +class CustomNetwork(PluginConfig): + """ + A custom network config. + """ + + name: str + """Name of the network e.g. mainnet.""" + + chain_id: int + """Chain ID (required).""" + + ecosystem: str + """The name of the ecosystem.""" + + base_ecosystem_plugin: Optional[str] = None + """The base ecosystem plugin to use, when applicable. Defaults to the default ecosystem.""" + + default_provider: str = "node" + """The default provider plugin to use. Default is the default node provider.""" + + request_header: dict = {} + """The HTTP request header.""" + + @property + def is_fork(self) -> bool: + """ + ``True`` when the name of the network ends in ``"-fork"``. + """ + return self.name.endswith("-fork") + + +class NetworksConfig(PluginConfig): + custom: list[CustomNetwork] = [] diff --git a/src/ape_plugins/__init__.py b/src/ape_plugins/__init__.py index 8826fdd2ce..734889b268 100644 --- a/src/ape_plugins/__init__.py +++ b/src/ape_plugins/__init__.py @@ -1,7 +1,8 @@ -from ape import plugins -from ape.api.config import ConfigDict +from ape.plugins import Config, register -@plugins.register(plugins.Config) +@register(Config) def config_class(): + from ape.api.config import ConfigDict + return ConfigDict diff --git a/tests/functional/test_compilers.py b/tests/functional/test_compilers.py index 48ed938828..58db5f44b8 100644 --- a/tests/functional/test_compilers.py +++ b/tests/functional/test_compilers.py @@ -8,7 +8,7 @@ from ape.contracts import ContractContainer from ape.exceptions import APINotImplementedError, CompilerError, ContractLogicError, CustomError from ape.types.address import AddressType -from ape_compile import Config +from ape_compile.config import Config def test_get_imports(project, compilers): diff --git a/tests/integration/cli/test_accounts.py b/tests/integration/cli/test_accounts.py index a0087033e3..1fea64efd5 100644 --- a/tests/integration/cli/test_accounts.py +++ b/tests/integration/cli/test_accounts.py @@ -139,7 +139,7 @@ def invoke_import(): @run_once def test_import_account_instantiation_failure(mocker, ape_cli, runner): - eth_account_from_key_patch = mocker.patch("ape_accounts._cli.EthAccount.from_key") + eth_account_from_key_patch = mocker.patch("ape_accounts._cli._account_from_key") eth_account_from_key_patch.side_effect = Exception("Can't instantiate this account!") result = runner.invoke( ape_cli,