diff --git a/src/ape/cli/choices.py b/src/ape/cli/choices.py index dd3685ab67..628707b513 100644 --- a/src/ape/cli/choices.py +++ b/src/ape/cli/choices.py @@ -6,14 +6,9 @@ from typing import TYPE_CHECKING, Any, Optional, Union import click -from click import BadParameter, Choice, Context, Parameter +from click import Choice, Context, Parameter -from ape.exceptions import ( - AccountsError, - EcosystemNotFoundError, - NetworkNotFoundError, - ProviderNotFoundError, -) +from ape.exceptions import AccountsError if TYPE_CHECKING: from ape.api.accounts import AccountAPI @@ -360,7 +355,7 @@ def __init__( ecosystem: _NETWORK_FILTER = None, network: _NETWORK_FILTER = None, provider: _NETWORK_FILTER = None, - base_type: Optional[type] = None, + base_type: Optional[Union[type, str]] = None, callback: Optional[Callable] = None, ): self._base_type = base_type @@ -372,15 +367,14 @@ def __init__( # NOTE: Purposely avoid super().init for performance reasons. @property - def base_type(self) -> type["ProviderAPI"]: + def base_type(self) -> Union[type["ProviderAPI"], str]: if self._base_type is not None: return self._base_type - # perf: property exists to delay import ProviderAPI at init time. - from ape.api.providers import ProviderAPI - - self._base_type = ProviderAPI - return ProviderAPI + # perf: Keep base-type as a forward-ref when only using the default. + # so things load faster. + self._base_type = "ProviderAPI" + return self._base_type @base_type.setter def base_type(self, value): @@ -394,62 +388,17 @@ 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: - choice = None + if not value or value.lower() in ("none", "null"): + return self.callback(ctx, param, _NONE_NETWORK) if self.callback else _NONE_NETWORK - elif value.lower() in ("none", "null"): - choice = _NONE_NETWORK + if self.base_type == "ProviderAPI" or isinstance(self.base_type, type): + # Return the provider. + from ape.utils.basemodel import ManagerAccessMixin as access - elif self.is_custom_value(value): - # By-pass choice constraints when using custom network. - choice = value + networks = access.network_manager + value = networks.get_provider_from_choice(network_choice=value) - else: - # Regular conditions. - try: - # Validate result. - choice = super().convert(value, param, ctx) - except BadParameter: - # Attempt to get the provider anyway. - # Sometimes, depending on the provider, it'll still work. - # (as-is the case for custom-forked networks). - try: - choice = networks.get_provider_from_choice(network_choice=value) - - except (EcosystemNotFoundError, NetworkNotFoundError, ProviderNotFoundError) as err: - # This error makes more sense, as it has attempted parsing. - # Show this message as the BadParameter message. - raise click.BadParameter(str(err)) from err - - except Exception as err: - # If an error was not raised for some reason, raise a simpler error. - # NOTE: Still avoid showing the massive network options list. - raise click.BadParameter( - "Invalid network choice. Use `ape networks list` to see options." - ) from err - - if choice not in (None, _NONE_NETWORK) and isinstance(choice, str): - from ape.api.providers import ProviderAPI - - if issubclass(self.base_type, ProviderAPI): - # Return the provider. - choice = networks.get_provider_from_choice(network_choice=value) - - return self.callback(ctx, param, choice) if self.callback else choice - - @classmethod - def is_custom_value(cls, value) -> bool: - return ( - value is not None - and isinstance(value, str) - and cls.CUSTOM_NETWORK_PATTERN.match(value) is not None - or str(value).startswith("http://") - or str(value).startswith("https://") - ) + return self.callback(ctx, param, value) if self.callback else value class OutputFormat(Enum): diff --git a/src/ape/cli/options.py b/src/ape/cli/options.py index 88c715c040..3ad8908c5c 100644 --- a/src/ape/cli/options.py +++ b/src/ape/cli/options.py @@ -528,6 +528,10 @@ def handle_parse_result(self, ctx, opts, args): return IncompatibleOption +def _project_path_callback(ctx, param, val): + return Path(val) if val else Path.cwd() + + def _project_callback(ctx, param, val): if "--help" in sys.argv or "-h" in sys.argv: # Perf: project option is eager; have to check sys.argv to @@ -560,10 +564,12 @@ def _project_callback(ctx, param, val): def project_option(**kwargs): + _type = kwargs.pop("type", None) + callback = _project_path_callback if issubclass(_type, Path) else _project_callback return click.option( "--project", help="The path to a local project or manifest", - callback=_project_callback, + callback=callback, metavar="PATH", is_eager=True, **kwargs, diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index a30c63b06e..005b5fd314 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -4,7 +4,7 @@ from evmchains import PUBLIC_CHAIN_META -from ape.api.networks import EcosystemAPI, NetworkAPI, ProviderContextManager +from ape.api.networks import ProviderContextManager from ape.exceptions import EcosystemNotFoundError, NetworkError, NetworkNotFoundError from ape.managers.base import BaseManager from ape.utils.basemodel import ( @@ -17,6 +17,7 @@ from ape_ethereum.provider import EthereumNodeProvider if TYPE_CHECKING: + from ape.api.networks import EcosystemAPI, NetworkAPI from ape.api.providers import ProviderAPI from ape.utils.rpc import RPCHeaders @@ -62,7 +63,7 @@ def active_provider(self, new_value: "ProviderAPI"): self._active_provider = new_value @property - def network(self) -> NetworkAPI: + def network(self) -> "NetworkAPI": """ The current network if connected to one. @@ -76,7 +77,7 @@ def network(self) -> NetworkAPI: return self.provider.network @property - def ecosystem(self) -> EcosystemAPI: + def ecosystem(self) -> "EcosystemAPI": """ The current ecosystem if connected to one. @@ -194,28 +195,38 @@ def custom_networks(self) -> list[dict]: Custom network data defined in various ape-config files or added adhoc to the network manager. """ + return [*self._custom_networks_from_config, *self._custom_networks] + + @cached_property + def _custom_networks_from_config(self) -> list[dict]: return [ - *[ - n.model_dump(by_alias=True) - for n in self.config_manager.get_config("networks").get("custom", []) - ], - *self._custom_networks, + n.model_dump(by_alias=True) + for n in self.config_manager.get_config("networks").get("custom", []) ] @property - def ecosystems(self) -> dict[str, EcosystemAPI]: + def ecosystems(self) -> dict[str, "EcosystemAPI"]: """ All the registered ecosystems in ``ape``, such as ``ethereum``. """ plugin_ecosystems = self._plugin_ecosystems + custom_ecosystems = self._custom_ecosystems + return {**custom_ecosystems, **plugin_ecosystems} + + @cached_property + def _plugin_ecosystems(self) -> dict[str, "EcosystemAPI"]: + # Load plugins. + plugins = self.plugin_manager.ecosystems + return {n: cls(name=n) for n, cls in plugins} # type: ignore[operator] - # Load config-based custom ecosystems. - # NOTE: Non-local projects will automatically add their custom networks - # to `self.custom_networks`. + @cached_property + def _custom_ecosystems(self) -> dict[str, "EcosystemAPI"]: custom_networks: list = self.custom_networks + plugin_ecosystems = self._plugin_ecosystems + custom_ecosystems: dict[str, "EcosystemAPI"] = {} for custom_network in custom_networks: ecosystem_name = custom_network["ecosystem"] - if ecosystem_name in plugin_ecosystems: + if ecosystem_name in plugin_ecosystems or ecosystem_name in custom_ecosystems: # Already included in a prior network. continue @@ -239,15 +250,9 @@ def ecosystems(self) -> dict[str, EcosystemAPI]: update={"name": ecosystem_name}, cache_clear=("_networks_from_plugins", "_networks_from_evmchains"), ) - plugin_ecosystems[ecosystem_name] = ecosystem_cls + custom_ecosystems[ecosystem_name] = ecosystem_cls - return plugin_ecosystems - - @cached_property - def _plugin_ecosystems(self) -> dict[str, EcosystemAPI]: - # Load plugins. - plugins = self.plugin_manager.ecosystems - return {n: cls(name=n) for n, cls in plugins} # type: ignore[operator] + return custom_ecosystems def create_custom_provider( self, @@ -323,7 +328,7 @@ def __ape_extra_attributes__(self) -> Iterator[ExtraModelAttributes]: ) @only_raise_attribute_error - def __getattr__(self, attr_name: str) -> EcosystemAPI: + def __getattr__(self, attr_name: str) -> "EcosystemAPI": """ Get an ecosystem via ``.`` access. @@ -424,7 +429,7 @@ def get_network_choices( if ecosystem_has_providers: yield ecosystem_name - def get_ecosystem(self, ecosystem_name: str) -> EcosystemAPI: + def get_ecosystem(self, ecosystem_name: str) -> "EcosystemAPI": """ Get the ecosystem for the given name. @@ -437,30 +442,45 @@ def get_ecosystem(self, ecosystem_name: str) -> EcosystemAPI: Returns: :class:`~ape.api.networks.EcosystemAPI` """ + # NOTE: This method purposely avoids "just checking self.ecosystems" + # for performance reasons and exiting the search as early as possible. + ecosystem_name = ecosystem_name.lower().replace(" ", "-") + try: + return self._plugin_ecosystems[ecosystem_name] + except KeyError: + pass - if ecosystem_name in self.ecosystem_names: - return self.ecosystems[ecosystem_name] + # Check if custom. + try: + return self._custom_ecosystems[ecosystem_name] + except KeyError: + pass - elif ecosystem_name.lower().replace(" ", "-") in PUBLIC_CHAIN_META: - ecosystem_name = ecosystem_name.lower().replace(" ", "-") - symbol = None - for net in PUBLIC_CHAIN_META[ecosystem_name].values(): - if not (native_currency := net.get("nativeCurrency")): - continue + if ecosystem := self._get_ecosystem_from_evmchains(ecosystem_name): + return ecosystem - if "symbol" not in native_currency: - continue + raise EcosystemNotFoundError(ecosystem_name, options=self.ecosystem_names) - symbol = native_currency["symbol"] - break + def _get_ecosystem_from_evmchains(self, ecosystem_name: str) -> Optional["EcosystemAPI"]: + if ecosystem_name not in PUBLIC_CHAIN_META: + return None - symbol = symbol or "ETH" + symbol = None + for net in PUBLIC_CHAIN_META[ecosystem_name].values(): + if not (native_currency := net.get("nativeCurrency")): + continue - # Is an EVM chain, can automatically make a class using evm-chains. - evm_class = self._plugin_ecosystems["ethereum"].__class__ - return evm_class(name=ecosystem_name, fee_token_symbol=symbol) + if "symbol" not in native_currency: + continue - raise EcosystemNotFoundError(ecosystem_name, options=self.ecosystem_names) + symbol = native_currency["symbol"] + break + + symbol = symbol or "ETH" + + # Is an EVM chain, can automatically make a class using evm-chains. + evm_class = self._plugin_ecosystems["ethereum"].__class__ + return evm_class(name=ecosystem_name, fee_token_symbol=symbol) def get_provider_from_choice( self, @@ -582,10 +602,10 @@ def default_ecosystem_name(self) -> str: if name := self._default_ecosystem_name: return name - return self.config_manager.default_ecosystem or "ethereum" + return self.local_project.config.default_ecosystem or "ethereum" - @property - def default_ecosystem(self) -> EcosystemAPI: + @cached_property + def default_ecosystem(self) -> "EcosystemAPI": """ The default ecosystem. Call :meth:`~ape.managers.networks.NetworkManager.set_default_ecosystem` to @@ -593,7 +613,7 @@ def default_ecosystem(self) -> EcosystemAPI: only a single ecosystem installed, such as Ethereum, then get that ecosystem. """ - return self.ecosystems[self.default_ecosystem_name] + return self.get_ecosystem(self.default_ecosystem_name) def set_default_ecosystem(self, ecosystem_name: str): """ diff --git a/src/ape/managers/plugins.py b/src/ape/managers/plugins.py index c20383f040..bec91e742f 100644 --- a/src/ape/managers/plugins.py +++ b/src/ape/managers/plugins.py @@ -1,6 +1,7 @@ from collections.abc import Generator, Iterable, Iterator from functools import cached_property from importlib import import_module +from itertools import chain from typing import Any, Optional from ape.exceptions import ApeAttributeError @@ -123,10 +124,8 @@ def _register_plugins(self): if self.__registered: return - plugins = list({n.replace("-", "_") for n in get_plugin_dists()}) - plugin_modules = tuple([*plugins, *CORE_PLUGINS]) - - for module_name in plugin_modules: + plugins = ({n.replace("-", "_") for n in get_plugin_dists()}) + for module_name in chain(plugins, iter(CORE_PLUGINS)): try: module = import_module(module_name) pluggy_manager.register(module) diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index 9fd69439db..8841f16fd6 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -2281,10 +2281,10 @@ def project_api(self) -> ProjectAPI: return default_project # ape-config.yaml does no exist. Check for another ProjectAPI type. - project_classes: list[type[ProjectAPI]] = [ - t[1] for t in list(self.plugin_manager.projects) # type: ignore - ] - plugins = [t for t in project_classes if not issubclass(t, ApeProject)] + project_classes: Iterator[type[ProjectAPI]] = ( + t[1] for t in self.plugin_manager.projects # type: ignore + ) + plugins = (t for t in project_classes if not issubclass(t, ApeProject)) for api in plugins: if instance := api.attempt_validate(path=self._base_path): return instance diff --git a/src/ape_console/_cli.py b/src/ape_console/_cli.py index a3d3431cac..423492b95a 100644 --- a/src/ape_console/_cli.py +++ b/src/ape_console/_cli.py @@ -2,11 +2,14 @@ import inspect import logging import sys +from functools import cached_property +from importlib import import_module from importlib.machinery import SourceFileLoader from importlib.util import module_from_spec, spec_from_loader from os import environ +from pathlib import Path from types import ModuleType -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, Union, cast import click @@ -39,7 +42,7 @@ def _code_callback(ctx, param, value) -> list[str]: context_settings=dict(ignore_unknown_options=True), ) @ape_cli_context() -@project_option(hidden=True) # Hidden as mostly used for test purposes. +@project_option(hidden=True, type=Path) # Hidden as mostly used for test purposes. @click.option("-c", "--code", help="Program passed in as a string", callback=_code_callback) def cli(cli_ctx, project, code): """Opens a console for the local project.""" @@ -60,51 +63,103 @@ def import_extras_file(file_path) -> ModuleType: return module -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) - - for extras_file in [global_extras, project_extras]: +class ApeConsoleNamespace(dict): + def __init__(self, **kwargs): + # Initialize the dictionary with provided keyword arguments + super().__init__(**kwargs) + + def __getitem__(self, key: str): + # First, attempt to retrieve the key from the dictionary itself + if super().__contains__(key): + return super().__getitem__(key) + + # Custom behavior for "ape" key + if key == "ape": + res = self._ape + self[key] = res # Cache the result + return res + + # Attempt to get the key from extras + try: + res = self._get_extra(key) + except KeyError: + pass + else: + self[key] = res # Cache the result + return res + + # Attempt to retrieve the key from the Ape module + try: + res = self._get_from_ape(key) + except AttributeError: + raise KeyError(key) + + # Cache the result and return + self[key] = res + return res + + def __setitem__(self, key, value): + # Override to set items directly into the dictionary + super().__setitem__(key, value) + + def __contains__(self, item: str) -> bool: # type: ignore + return self.get(item) is not None + + def update(self, mapping, **kwargs) -> None: # type: ignore + # Override to update the dictionary directly + super().update(mapping, **kwargs) + + @property + def _ape(self) -> ModuleType: + return import_module("ape") + + @cached_property + def _local_extras(self) -> dict: + path = self._ape.project.path.joinpath(CONSOLE_EXTRAS_FILENAME) + return self._load_extras_file(path) + + @cached_property + def _global_extras(self) -> dict: + path = self._ape.project.config_manager.DATA_FOLDER.joinpath(CONSOLE_EXTRAS_FILENAME) + return self._load_extras_file(path) + + def get(self, key: str, default: Optional[Any] = None): + try: + return self.__getitem__(key) + except KeyError: + return default + + def _get_extra(self, key: str): + try: + return self._local_extras[key] + except KeyError: + return self._global_extras[key] + + def _get_from_ape(self, key: str): + return getattr(self._ape, key) + + def _load_extras_file(self, extras_file: Path) -> dict: if not extras_file.is_file(): - continue + return {} module = import_extras_file(extras_file) ape_init_extras = getattr(module, "ape_init_extras", None) + all_extras: dict = {} - # If found, execute ape_init_extras() function. if ape_init_extras is not None: - # Figure out the kwargs the func is looking for and assemble - # from the original namespace func_spec = inspect.getfullargspec(ape_init_extras) - init_kwargs: dict[str, Any] = {k: namespace.get(k) for k in func_spec.args} - - # Execute functionality with existing console namespace as - # kwargs. + init_kwargs: dict[str, Any] = {k: self._get_from_ape(k) for k in func_spec.args} extras = ape_init_extras(**init_kwargs) - # If ape_init_extras returned a dict expect it to be new symbols if isinstance(extras, dict): - namespace.update(extras) + all_extras.update(extras) - # Add any public symbols from the module into the console namespace - for k in dir(module): - if k != "ape_init_extras" and not k.startswith("_"): - # Prevent override of existing namespace symbols - if k in namespace: - continue - - namespace[k] = getattr(module, k) - - return namespace + all_extras.update({k: getattr(module, k) for k in dir(module) if k not in all_extras}) + return all_extras def console( - project: Optional["ProjectManager"] = None, + project: Optional[Union["ProjectManager", Path]] = None, verbose: bool = False, extra_locals: Optional[dict] = None, embed: bool = False, @@ -113,11 +168,15 @@ def console( import IPython 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 + if project is None: + from ape.utils.basemodel import ManagerAccessMixin + + project = ManagerAccessMixin.local_project + + project_path: Path = project if isinstance(project, Path) else project.path banner = "" if verbose: banner = """ @@ -131,29 +190,14 @@ def console( python_version=_python_version, ipython_version=IPython.__version__, ape_version=ape_version, - project_path=project.path, + project_path=project_path, ) if not environ.get("APE_TESTING"): faulthandler.enable() # NOTE: In case we segfault - namespace = {component: getattr(ape, component) for component in ape.__all__} - namespace["project"] = project # Use the given project. - namespace["ape"] = ape - # Allows modules relative to the project. - sys.path.insert(0, f"{project.path}") - - # NOTE: `ape_console_extras` only is meant to work with default namespace. - # Load extras before local namespace to avoid console extras receiving - # the wrong values for its arguments. - console_extras = load_console_extras(**namespace) - - if extra_locals: - namespace.update(extra_locals) - - if console_extras: - namespace.update(console_extras) + sys.path.insert(0, f"{project_path}") ipy_config = IPythonConfig() ape_testing = environ.get("APE_TESTING") @@ -163,6 +207,7 @@ def console( # Required for click.testing.CliRunner support. embed = True + namespace = ApeConsoleNamespace(**(extra_locals or {})) _launch_console(namespace, ipy_config, embed, banner, code=code)