diff --git a/src/ape/_cli.py b/src/ape/_cli.py index bd9b2b38e3..f51778e1cd 100644 --- a/src/ape/_cli.py +++ b/src/ape/_cli.py @@ -1,7 +1,7 @@ import difflib import re import sys -from typing import Any, Dict +from typing import Any, Dict, List, Optional import click import importlib_metadata as metadata @@ -29,7 +29,8 @@ def display_config(ctx, param, value): class ApeCLI(click.MultiCommand): - _commands = None + _commands: Optional[Dict] = None + _CLI_GROUP_NAME = "ape_cli_subcommands" def invoke(self, ctx) -> Any: try: @@ -48,11 +49,14 @@ def _suggest_cmd(usage_error): if usage_error.message is None: raise usage_error - match = re.match("No such command '(.*)'.", usage_error.message) - if not match: + elif not (match := re.match("No such command '(.*)'.", usage_error.message)): raise usage_error - bad_arg = match.groups()[0] + groups = match.groups() + if len(groups) < 1: + raise usage_error + + bad_arg = groups[0] suggested_commands = difflib.get_close_matches( bad_arg, list(usage_error.ctx.command.commands.keys()), cutoff=_DIFFLIB_CUT_OFF ) @@ -66,30 +70,22 @@ def _suggest_cmd(usage_error): @property def commands(self) -> Dict: - group_name = "ape_cli_subcommands" - if not self._commands: - try: - entry_points = metadata.entry_points(group=group_name) - except TypeError: - entry_points = metadata.entry_points() - entry_points = ( - entry_points[group_name] if group_name in entry_points else [] # type: ignore - ) - - if not entry_points: - raise Abort("Missing registered cli subcommands") + if self._commands: + return self._commands - self._commands = { - clean_plugin_name(entry_point.name): entry_point.load - for entry_point in entry_points - } + entry_points = metadata.entry_points(group=self._CLI_GROUP_NAME) + if not entry_points: + raise Abort("Missing registered CLI subcommands.") + self._commands = { + clean_plugin_name(entry_point.name): entry_point.load for entry_point in entry_points + } return self._commands - def list_commands(self, ctx): + def list_commands(self, ctx) -> List[str]: return list(sorted(self.commands)) - def get_command(self, ctx, name): + def get_command(self, ctx, name) -> Optional[click.Command]: if name in self.commands: try: return self.commands[name]() @@ -99,6 +95,7 @@ def get_command(self, ctx, name): ) # NOTE: don't return anything so Click displays proper error + return None @click.command(cls=ApeCLI, context_settings=dict(help_option_names=["-h", "--help"])) diff --git a/src/ape/cli/arguments.py b/src/ape/cli/arguments.py index d6306c0aff..2a61cbc2fe 100644 --- a/src/ape/cli/arguments.py +++ b/src/ape/cli/arguments.py @@ -55,13 +55,11 @@ def _raise_bad_arg(name): resolved_contract_paths = set() for contract_path in contract_paths: # Adds missing absolute path as well as extension. - pm = project if ctx.obj is None else ctx.obj.project_manager - resolved_contract_path = pm.lookup_path(contract_path) - if not resolved_contract_path: + if resolved_contract_path := project.lookup_path(contract_path): + resolved_contract_paths.add(resolved_contract_path) + else: _raise_bad_arg(contract_path.name) - resolved_contract_paths.add(resolved_contract_path) - return resolved_contract_paths diff --git a/src/ape/cli/choices.py b/src/ape/cli/choices.py index 5d509b8570..387ef5a112 100644 --- a/src/ape/cli/choices.py +++ b/src/ape/cli/choices.py @@ -1,6 +1,7 @@ import re from enum import Enum -from typing import Any, List, Optional, Type, Union +from functools import lru_cache +from typing import Any, Iterator, List, Optional, Sequence, Type, Union import click from click import Choice, Context, Parameter @@ -8,6 +9,7 @@ from ape import accounts, networks from ape.api.accounts import AccountAPI from ape.exceptions import AccountsError +from ape.types import _LazySequence ADHOC_NETWORK_PATTERN = re.compile(r"\w*:\w*:https?://\w*.*") @@ -34,18 +36,15 @@ def __init__(self, account_type: Optional[Type[AccountAPI]] = None): # NOTE: we purposely skip the constructor of `Choice` self.case_sensitive = False self._account_type = account_type + self.choices = _LazySequence(self._choices_iterator) @property - def choices(self) -> List[str]: # type: ignore - """ - The aliases available to choose from. - - Returns: - List[str]: A list of account aliases the user may choose from. - """ + def _choices_iterator(self) -> Iterator[str]: + for acct in _get_account_by_type(self._account_type): + if acct.alias is None: + continue - options = _get_account_by_type(self._account_type) - return [a.alias for a in options if a.alias is not None] + yield acct.alias class PromptChoice(click.ParamType): @@ -67,7 +66,7 @@ def cmd(choice): click.echo(f"__expected_{choice}") """ - def __init__(self, choices, name: Optional[str] = None): + def __init__(self, choices: Sequence[str], name: Optional[str] = None): self.choices = choices # Since we purposely skip the super() constructor, we need to make # sure the class still has a name. @@ -159,6 +158,7 @@ def __init__( self._account_type = account_type self._prompt_message = prompt_message or "Select an account" self.name = name + self.choices = _LazySequence(self._choices_iterator) def convert( self, value: Any, param: Optional[Parameter], ctx: Optional[Context] @@ -201,21 +201,15 @@ def print_choices(self): click.echo() @property - def choices(self) -> List[str]: - """ - All the account aliases. + def _choices_iterator(self) -> Iterator[str]: + # Yield real accounts. + for account in _get_account_by_type(self._account_type): + if account and (alias := account.alias): + yield alias - Returns: - List[str]: A list of all the account aliases. - """ - - _accounts = [ - a.alias - for a in _get_account_by_type(self._account_type) - if a is not None and a.alias is not None - ] - _accounts.extend([f"TEST::{i}" for i, _ in enumerate(accounts.test_accounts)]) - return _accounts + # Yield test accounts (at the end). + for idx, _ in enumerate(accounts.test_accounts): + yield f"TEST::{idx}" def get_user_selected_account(self) -> AccountAPI: """ @@ -239,6 +233,53 @@ def fail_from_invalid_choice(self, param): return self.fail("Invalid choice. Type the number or the alias.", param=param) +_NETWORK_FILTER = Optional[Union[List[str], str]] + + +def get_networks( + ecosystem: _NETWORK_FILTER = None, + network: _NETWORK_FILTER = None, + provider: _NETWORK_FILTER = None, +) -> _LazySequence: + # NOTE: Use str-keys and lru_cache. + return _get_networks_sequence_from_cache( + _network_filter_to_key(ecosystem), + _network_filter_to_key(network), + _network_filter_to_key(provider), + ) + + +@lru_cache(maxsize=None) +def _get_networks_sequence_from_cache(ecosystem_key: str, network_key: str, provider_key: str): + return _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), + ) + ) + + +def _network_filter_to_key(filter_: _NETWORK_FILTER) -> str: + if filter_ is None: + return "__none__" + + elif isinstance(filter_, list): + return ",".join(filter_) + + return filter_ + + +def _key_to_network_filter(key: str) -> _NETWORK_FILTER: + if key == "__none__": + return None + + elif "," in key: + return [n.strip() for n in key.split(",")] + + return key + + class NetworkChoice(click.Choice): """ A ``click.Choice`` to provide network choice defaults for the active project. @@ -252,17 +293,12 @@ class NetworkChoice(click.Choice): def __init__( self, case_sensitive=True, - ecosystem: Optional[Union[List[str], str]] = None, - network: Optional[Union[List[str], str]] = None, - provider: Optional[Union[List[str], str]] = None, + ecosystem: _NETWORK_FILTER = None, + network: _NETWORK_FILTER = None, + provider: _NETWORK_FILTER = None, ): super().__init__( - list( - networks.get_network_choices( - ecosystem_filter=ecosystem, network_filter=network, provider_filter=provider - ) - ), - case_sensitive, + get_networks(ecosystem=ecosystem, network=network, provider=provider), case_sensitive ) def get_metavar(self, param): @@ -306,7 +342,7 @@ def output_format_choice(options: Optional[List[OutputFormat]] = None) -> Choice :class:`click.Choice` """ - options = options or [o for o in OutputFormat] + options = options or list(OutputFormat) # Uses `str` form of enum for CLI choices. return click.Choice([o.value for o in options], case_sensitive=False) diff --git a/src/ape/cli/options.py b/src/ape/cli/options.py index 61ebb7c90a..1341c5b81b 100644 --- a/src/ape/cli/options.py +++ b/src/ape/cli/options.py @@ -1,4 +1,4 @@ -from typing import Dict, List, NoReturn, Optional, Union +from typing import Callable, Dict, List, NoReturn, Optional, Union import click from ethpm_types import ContractType @@ -27,7 +27,6 @@ class ApeCliContextObject(ManagerAccessMixin): def __init__(self): self.logger = logger - self.config_manager.load() @staticmethod def abort(msg: str, base_error: Optional[Exception] = None) -> NoReturn: @@ -96,7 +95,7 @@ def decorator(f): def network_option( - default: Optional[str] = "auto", + default: Optional[Union[str, Callable]] = "auto", ecosystem: Optional[Union[List[str], str]] = None, network: Optional[Union[List[str], str]] = None, provider: Optional[Union[List[str], str]] = None, @@ -126,8 +125,13 @@ def network_option( if auto and not required: if ecosystem: default = ecosystem[0] if isinstance(ecosystem, (list, tuple)) else ecosystem + else: - default = networks.default_ecosystem.name + # NOTE: Use a function as the default so it is calculated lazily + def fn(): + return networks.default_ecosystem.name + + default = fn elif auto: default = None diff --git a/src/ape/plugins/__init__.py b/src/ape/plugins/__init__.py index d27ca8f90b..2b6a61f363 100644 --- a/src/ape/plugins/__init__.py +++ b/src/ape/plugins/__init__.py @@ -121,6 +121,10 @@ class PluginManager: _unimplemented_plugins: List[str] = [] def __init__(self) -> None: + self.__registered = False + + @functools.cached_property + def _plugin_modules(self) -> Tuple[str, ...]: # NOTE: Unable to use pkgutil.iter_modules() for installed plugins # because it does not work with editable installs. # See https://github.com/python/cpython/issues/99805. @@ -134,23 +138,18 @@ def __init__(self) -> None: core_plugin_module_names = { n for _, n, ispkg in pkgutil.iter_modules() if n.startswith("ape_") } - module_names = installed_plugin_module_names.union(core_plugin_module_names) - - for module_name in module_names: - try: - module = importlib.import_module(module_name) - plugin_manager.register(module) - except Exception as err: - if module_name in __modules__: - # Always raise core plugin registration errors. - raise - logger.warn_from_exception(err, f"Error loading plugin package '{module_name}'.") + # NOTE: Returns tuple because this shouldn't change. + return tuple(installed_plugin_module_names.union(core_plugin_module_names)) def __repr__(self): return f"<{self.__class__.__name__}>" def __getattr__(self, attr_name: str) -> Iterator[Tuple[str, Tuple]]: + # NOTE: The first time this method is called, the actual + # plugin registration occurs. Registration only happens once. + self._register_plugins() + if not hasattr(plugin_manager.hook, attr_name): raise ApeAttributeError(f"{self.__class__.__name__} has no attribute '{attr_name}'.") @@ -174,6 +173,23 @@ def get_plugin_name_and_hookfn(h): if validated_plugin: yield validated_plugin + def _register_plugins(self): + if self.__registered: + return + + for module_name in self._plugin_modules: + try: + module = importlib.import_module(module_name) + plugin_manager.register(module) + except Exception as err: + if module_name in __modules__: + # Always raise core plugin registration errors. + raise + + logger.warn_from_exception(err, f"Error loading plugin package '{module_name}'.") + + self.__registered = True + def _validate_plugin(self, plugin_name: str, plugin_cls) -> Optional[Tuple[str, Tuple]]: if valid_impl(plugin_cls): return clean_plugin_name(plugin_name), plugin_cls diff --git a/src/ape/types/__init__.py b/src/ape/types/__init__.py index 8f110d2eee..131382e26b 100644 --- a/src/ape/types/__init__.py +++ b/src/ape/types/__init__.py @@ -2,14 +2,17 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Dict, Iterator, List, Literal, Optional, Sequence, + TypeVar, Union, cast, + overload, ) from eth_abi.abi import encode @@ -415,6 +418,64 @@ def __contains__(self, val: Any) -> bool: return any(log == val for log in self) +_T = TypeVar("_T") # _LazySequence generic. + + +class _LazySequence(Sequence[_T]): + def __init__(self, generator: Union[Iterator[_T], Callable[[], Iterator[_T]]]): + self._generator = generator + self.cache: List = [] + + @overload + def __getitem__(self, index: int) -> _T: + ... + + @overload + def __getitem__(self, index: slice) -> Sequence[_T]: + ... + + def __getitem__(self, index: Union[int, slice]) -> Union[_T, Sequence[_T]]: + if isinstance(index, int): + while len(self.cache) <= index: + # Catch up the cache. + if value := next(self.generator, None): + self.cache.append(value) + + return self.cache[index] + + elif isinstance(index, slice): + # TODO: Make slices lazier. Right now, it deqeues all. + for item in self.generator: + self.cache.append(item) + + return self.cache[index] + + else: + raise TypeError("Index must be int or slice.") + + def __len__(self) -> int: + # NOTE: This will deque everything. + + for value in self.generator: + self.cache.append(value) + + return len(self.cache) + + def __iter__(self) -> Iterator[_T]: + yield from self.cache + for value in self.generator: + yield value + self.cache.append(value) + + @property + def generator(self) -> Iterator: + if callable(self._generator): + self._generator = self._generator() + + assert isinstance(self._generator, Iterator) # For type-checking. + yield from self._generator + + __all__ = [ "ABI", "AddressType", diff --git a/src/ape_networks/_cli.py b/src/ape_networks/_cli.py index c7b61f1cc4..414b556fa8 100644 --- a/src/ape_networks/_cli.py +++ b/src/ape_networks/_cli.py @@ -10,6 +10,7 @@ from ape.cli.choices import OutputFormat from ape.cli.options import output_format_option from ape.logging import LogLevel +from ape.types import _LazySequence def _filter_option(name: str, options): @@ -29,12 +30,20 @@ def cli(): """ +def _lazy_get(name: str) -> _LazySequence: + # NOTE: Using fn generator to maintain laziness. + def gen(): + yield from getattr(networks, f"{name}_names") + + return _LazySequence(gen) + + @cli.command(name="list", short_help="List registered networks") @ape_cli_context() @output_format_option() -@_filter_option("ecosystem", networks.ecosystem_names) -@_filter_option("network", networks.network_names) -@_filter_option("provider", networks.provider_names) +@_filter_option("ecosystem", _lazy_get("ecosystem")) +@_filter_option("network", _lazy_get("network")) +@_filter_option("provider", _lazy_get("provider")) def _list(cli_ctx, output_format, ecosystem_filter, network_filter, provider_filter): if output_format == OutputFormat.TREE: default_suffix = "[dim default] (default)" diff --git a/src/ape_run/_cli.py b/src/ape_run/_cli.py index 8efaa1c7ec..737b6f9d8f 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 import project from ape.cli import NetworkBoundCommand, network_option, verbosity_option from ape.cli.options import _VERBOSITY_VALUES, _create_verbosity_kwargs from ape.exceptions import ApeException, handle_ape_exception from ape.logging import logger -from ape.managers.project import ProjectManager from ape.utils import get_relative_path, use_temp_sys_path from ape_console._cli import console @@ -88,7 +88,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, self._project.path) + relative_filepath = get_relative_path(filepath, project.path) # First load the code module by compiling it # NOTE: This does not execute the module @@ -167,28 +167,12 @@ def call(network): return call - @property - def _project(self) -> ProjectManager: - """ - A class representing the project that is active at runtime. - (This is the same object as from ``from ape import project``). - - Returns: - :class:`~ape.managers.project.ProjectManager` - """ - - from ape import project - - project.config_manager.load() - - return project - @property def commands(self) -> Dict[str, Union[click.Command, click.Group]]: - if not self._project.scripts_folder.is_dir(): + if not project.scripts_folder.is_dir(): return {} - return self._get_cli_commands(self._project.scripts_folder) + return self._get_cli_commands(project.scripts_folder) def _get_cli_commands(self, base_path: Path) -> Dict: commands: Dict[str, Command] = {} @@ -234,9 +218,7 @@ def result_callback(self, result, interactive): def _launch_console(self): trace = inspect.trace() - trace_frames = [ - x for x in trace if x.filename.startswith(str(self._project.scripts_folder)) - ] + trace_frames = [x for x in trace if x.filename.startswith(str(project.scripts_folder))] if not trace_frames: # Error from Ape internals; avoid launching console. sys.exit(1) @@ -257,7 +239,7 @@ def _launch_console(self): if frame: del frame - return console(project=self._project, extra_locals=extra_locals, embed=True) + return console(project=project, extra_locals=extra_locals, embed=True) @click.command( diff --git a/tests/integration/cli/test_run.py b/tests/integration/cli/test_run.py index c5d4824376..5e3485989d 100644 --- a/tests/integration/cli/test_run.py +++ b/tests/integration/cli/test_run.py @@ -21,7 +21,7 @@ def test_run(ape_cli, runner, project): scripts = [s for s in project.scripts_folder.glob("*.py") if not s.name.startswith("error")] for script_file in scripts: - result = runner.invoke(ape_cli, ["run", script_file.stem]) + result = runner.invoke(ape_cli, ["run", script_file.stem], catch_exceptions=False) assert ( result.exit_code == 0 ), f"Unexpected exit code for '{script_file.name}'\n{result.output}"