From 2515e642c018c0268d634a2052d731699c5e3053 Mon Sep 17 00:00:00 2001 From: antazoey Date: Fri, 1 Nov 2024 16:25:25 -0500 Subject: [PATCH 01/15] perf: make `ape console --help` faster (#2366) --- src/ape/cli/choices.py | 6 +++--- src/ape/cli/commands.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ape/cli/choices.py b/src/ape/cli/choices.py index d880e262bc..dd3685ab67 100644 --- a/src/ape/cli/choices.py +++ b/src/ape/cli/choices.py @@ -373,12 +373,12 @@ def __init__( @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 + # perf: property exists to delay import ProviderAPI at init time. + from ape.api.providers import ProviderAPI + self._base_type = ProviderAPI return ProviderAPI diff --git a/src/ape/cli/commands.py b/src/ape/cli/commands.py index ea6110d42c..3cf62b7d3f 100644 --- a/src/ape/cli/commands.py +++ b/src/ape/cli/commands.py @@ -71,10 +71,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def parse_args(self, ctx: "Context", args: list[str]) -> list[str]: - from ape.api.providers import ProviderAPI - arguments = args # Renamed for better pdb support. - base_type = ProviderAPI if self._use_cls_types else str + base_type: Optional[type] = None if self._use_cls_types else str if existing_option := next( iter( x @@ -85,13 +83,20 @@ def parse_args(self, ctx: "Context", args: list[str]) -> list[str]: ), None, ): + if base_type is None: + from ape.api.providers import ProviderAPI + + base_type = ProviderAPI + # Checking instance above, not sure why mypy still mad. existing_option.type.base_type = base_type # type: ignore else: # Add the option automatically. + # NOTE: Local import here only avoids circular import issues. from ape.cli.options import NetworkOption + # NOTE: None base-type will default to `ProviderAPI`. option = NetworkOption(base_type=base_type, callback=self._network_callback) self.params.append(option) From f8edd7c41bd4a96a33ef86d15134ecb439670938 Mon Sep 17 00:00:00 2001 From: antazoey Date: Fri, 1 Nov 2024 17:04:10 -0500 Subject: [PATCH 02/15] perf: make `ape test --help` faster (#2368) --- src/ape_test/__init__.py | 15 +++-- src/ape_test/_cli.py | 97 ++------------------------ src/ape_test/_watch.py | 105 +++++++++++++++++++++++++++++ tests/functional/test_test.py | 30 +++++++++ tests/integration/cli/test_test.py | 21 +----- 5 files changed, 151 insertions(+), 117 deletions(-) create mode 100644 src/ape_test/_watch.py diff --git a/src/ape_test/__init__.py b/src/ape_test/__init__.py index eabb36d1c9..3c9d5b0d46 100644 --- a/src/ape_test/__init__.py +++ b/src/ape_test/__init__.py @@ -5,20 +5,23 @@ @plugins.register(plugins.Config) def config_class(): - module = import_module("ape_test.config") - return module.ApeTestConfig + from ape_test.config import ApeTestConfig + + return ApeTestConfig @plugins.register(plugins.AccountPlugin) def account_types(): - module = import_module("ape_test.accounts") - return module.TestAccountContainer, module.TestAccount + from ape_test.accounts import TestAccount, TestAccountContainer + + return TestAccountContainer, TestAccount @plugins.register(plugins.ProviderPlugin) def providers(): - module = import_module("ape_test.provider") - yield "ethereum", "local", module.LocalProvider + from ape_test.provider import LocalProvider + + yield "ethereum", "local", LocalProvider def __getattr__(name: str): diff --git a/src/ape_test/_cli.py b/src/ape_test/_cli.py index 22541d51d6..a98baaffd5 100644 --- a/src/ape_test/_cli.py +++ b/src/ape_test/_cli.py @@ -1,80 +1,14 @@ import sys -import threading -import time -from datetime import datetime, timedelta -from functools import cached_property +from collections.abc import Iterable from pathlib import Path -from subprocess import run as run_subprocess from typing import Any import click import pytest from click import Command -from watchdog import events -from watchdog.observers import Observer from ape.cli.options import ape_cli_context from ape.logging import LogLevel, _get_level -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() -trigger = None - - -def emit_trigger(): - """ - Emits trigger to run pytest - """ - - global trigger - - with trigger_lock: - trigger = datetime.now() - - -class EventHandler(events.FileSystemEventHandler): - EVENTS_WATCHED = ( - events.EVENT_TYPE_CREATED, - events.EVENT_TYPE_DELETED, - events.EVENT_TYPE_MODIFIED, - events.EVENT_TYPE_MOVED, - ) - - def dispatch(self, event: events.FileSystemEvent) -> None: - if event.event_type in self.EVENTS_WATCHED: - self.process_event(event) - - @cached_property - def _extensions_to_watch(self) -> list[str]: - return [".py", *access.compiler_manager.registered_compilers.keys()] - - def _is_path_watched(self, filepath: str) -> bool: - """ - Check if file should trigger pytest run - """ - return any(map(filepath.endswith, self._extensions_to_watch)) - - def process_event(self, event: events.FileSystemEvent) -> None: - if self._is_path_watched(event.src_path): - emit_trigger() - - -def _run_ape_test(*pytest_args): - return run_subprocess(["ape", "test", *[f"{a}" for a in pytest_args]]) - - -def _run_main_loop(delay: float, *pytest_args: str) -> None: - global trigger - - now = datetime.now() - if trigger and now - trigger > timedelta(seconds=delay): - _run_ape_test(*pytest_args) - - with trigger_lock: - trigger = None - - time.sleep(delay) def _validate_pytest_args(*pytest_args) -> list[str]: @@ -176,25 +110,7 @@ def cli(cli_ctx, watch, watch_folders, watch_delay, pytest_args): pytest_arg_ls = _validate_pytest_args(*pytest_arg_ls) if watch: - event_handler = _create_event_handler() - observer = _create_observer() - - for folder in watch_folders: - if folder.is_dir(): - observer.schedule(event_handler, folder, recursive=True) - else: - cli_ctx.logger.warning(f"Folder '{folder}' doesn't exist or isn't a folder.") - - observer.start() - - try: - _run_ape_test(*pytest_arg_ls) - while True: - _run_main_loop(watch_delay, *pytest_arg_ls) - - finally: - observer.stop() - observer.join() + _run_with_observer(watch_folders, watch_delay, *pytest_arg_ls) else: return_code = pytest.main([*pytest_arg_ls], ["ape_test"]) @@ -203,11 +119,8 @@ def cli(cli_ctx, watch, watch_folders, watch_delay, pytest_args): sys.exit(return_code) -def _create_event_handler(): +def _run_with_observer(watch_folders: Iterable[Path], watch_delay: float, *pytest_arg_ls: str): # Abstracted for testing purposes. - return EventHandler() - + from ape_test._watch import run_with_observer as run -def _create_observer(): - # Abstracted for testing purposes. - return Observer() + run(watch_folders, watch_delay, *pytest_arg_ls) diff --git a/src/ape_test/_watch.py b/src/ape_test/_watch.py new file mode 100644 index 0000000000..95627cf4f4 --- /dev/null +++ b/src/ape_test/_watch.py @@ -0,0 +1,105 @@ +import threading +import time +from collections.abc import Iterable +from datetime import datetime, timedelta +from functools import cached_property +from pathlib import Path +from subprocess import run as run_subprocess + +from watchdog import events +from watchdog.observers import Observer + +from ape.logging import logger + +# Copied from https://github.com/olzhasar/pytest-watcher/blob/master/pytest_watcher/watcher.py +trigger_lock = threading.Lock() +trigger = None + + +def run_with_observer(watch_folders: Iterable[Path], watch_delay: float, *pytest_arg_ls: str): + event_handler = _create_event_handler() + observer = _create_observer() + + for folder in watch_folders: + if folder.is_dir(): + observer.schedule(event_handler, folder, recursive=True) + else: + logger.warning(f"Folder '{folder}' doesn't exist or isn't a folder.") + + observer.start() + + try: + _run_ape_test(*pytest_arg_ls) + while True: + _run_main_loop(watch_delay, *pytest_arg_ls) + + finally: + observer.stop() + observer.join() + + +def emit_trigger(): + """ + Emits trigger to run pytest + """ + + global trigger + + with trigger_lock: + trigger = datetime.now() + + +class EventHandler(events.FileSystemEventHandler): + EVENTS_WATCHED = ( + events.EVENT_TYPE_CREATED, + events.EVENT_TYPE_DELETED, + events.EVENT_TYPE_MODIFIED, + events.EVENT_TYPE_MOVED, + ) + + def dispatch(self, event: events.FileSystemEvent) -> None: + if event.event_type in self.EVENTS_WATCHED: + self.process_event(event) + + @cached_property + def _extensions_to_watch(self) -> list[str]: + from ape.utils.basemodel import ManagerAccessMixin as access + + return [".py", *access.compiler_manager.registered_compilers.keys()] + + def _is_path_watched(self, filepath: str) -> bool: + """ + Check if file should trigger pytest run + """ + return any(map(filepath.endswith, self._extensions_to_watch)) + + def process_event(self, event: events.FileSystemEvent) -> None: + if self._is_path_watched(event.src_path): + emit_trigger() + + +def _run_ape_test(*pytest_args): + return run_subprocess(["ape", "test", *[f"{a}" for a in pytest_args]]) + + +def _run_main_loop(delay: float, *pytest_args: str) -> None: + global trigger + + now = datetime.now() + if trigger and now - trigger > timedelta(seconds=delay): + _run_ape_test(*pytest_args) + + with trigger_lock: + trigger = None + + time.sleep(delay) + + +def _create_event_handler(): + # Abstracted for testing purposes. + return EventHandler() + + +def _create_observer(): + # Abstracted for testing purposes. + return Observer() diff --git a/tests/functional/test_test.py b/tests/functional/test_test.py index 56ee09c88a..e063973e0e 100644 --- a/tests/functional/test_test.py +++ b/tests/functional/test_test.py @@ -1,8 +1,11 @@ +from pathlib import Path + import pytest from ape.exceptions import ConfigError from ape.pytest.runners import PytestApeRunner from ape_test import ApeTestConfig +from ape_test._watch import run_with_observer class TestApeTestConfig: @@ -33,3 +36,30 @@ def test_connect_to_mainnet_by_default(mocker): ) with pytest.raises(ConfigError, match=expected): runner._connect() + + +def test_watch(mocker): + mock_event_handler = mocker.MagicMock() + event_handler_patch = mocker.patch("ape_test._watch._create_event_handler") + event_handler_patch.return_value = mock_event_handler + + mock_observer = mocker.MagicMock() + observer_patch = mocker.patch("ape_test._watch._create_observer") + observer_patch.return_value = mock_observer + + run_subprocess_patch = mocker.patch("ape_test._watch.run_subprocess") + run_main_loop_patch = mocker.patch("ape_test._watch._run_main_loop") + run_main_loop_patch.side_effect = SystemExit # Avoid infinite loop. + + # Only passing `-s` so we have an extra arg to test. + with pytest.raises(SystemExit): + run_with_observer((Path("contracts"),), 0.1, "-s") + + # The observer started, then the main runner exits, and the observer stops + joins. + assert mock_observer.start.call_count == 1 + assert mock_observer.stop.call_count == 1 + assert mock_observer.join.call_count == 1 + + # NOTE: We had a bug once where the args it received were not strings. + # (wasn't deconstructing), so this check is important. + run_subprocess_patch.assert_called_once_with(["ape", "test", "-s"]) diff --git a/tests/integration/cli/test_test.py b/tests/integration/cli/test_test.py index f7c09a615f..90cf0b184c 100644 --- a/tests/integration/cli/test_test.py +++ b/tests/integration/cli/test_test.py @@ -435,27 +435,10 @@ def test_fails(): @skip_projects_except("with-contracts") def test_watch(mocker, integ_project, runner, ape_cli): - mock_event_handler = mocker.MagicMock() - event_handler_patch = mocker.patch("ape_test._cli._create_event_handler") - event_handler_patch.return_value = mock_event_handler - - mock_observer = mocker.MagicMock() - observer_patch = mocker.patch("ape_test._cli._create_observer") - observer_patch.return_value = mock_observer - - run_subprocess_patch = mocker.patch("ape_test._cli.run_subprocess") - run_main_loop_patch = mocker.patch("ape_test._cli._run_main_loop") - run_main_loop_patch.side_effect = SystemExit # Avoid infinite loop. + runner_patch = mocker.patch("ape_test._cli._run_with_observer") # Only passing `-s` so we have an extra arg to test. result = runner.invoke(ape_cli, ("test", "--watch", "-s")) assert result.exit_code == 0 - # The observer started, then the main runner exits, and the observer stops + joins. - assert mock_observer.start.call_count == 1 - assert mock_observer.stop.call_count == 1 - assert mock_observer.join.call_count == 1 - - # NOTE: We had a bug once where the args it received were not strings. - # (wasn't deconstructing), so this check is important. - run_subprocess_patch.assert_called_once_with(["ape", "test", "-s"]) + runner_patch.assert_called_once_with((Path("contracts"), Path("tests")), 0.5, "-s") From 26dcf1311badd32b2713d5cc8550f39f1591c341 Mon Sep 17 00:00:00 2001 From: antazoey Date: Fri, 1 Nov 2024 17:19:50 -0500 Subject: [PATCH 03/15] perf: make `ape plugins --help` faster (#2367) --- src/ape_plugins/_cli.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index ecf5a80937..59f351ee66 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -1,23 +1,16 @@ import subprocess import sys from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import click from packaging.version import Version from ape.cli.options import ape_cli_context, skip_confirmation_option from ape.logging import logger -from ape.plugins._utils import ( - PIP_COMMAND, - ModifyPluginResultHandler, - PluginMetadata, - PluginMetadataList, - PluginType, - ape_version, - get_plugin_dists, -) -from ape.utils.misc import load_config + +if TYPE_CHECKING: + from ape.plugins._utils import PluginMetadata @click.group(short_help="Manage ape plugins") @@ -33,7 +26,10 @@ def plugins_argument(): or plugins loaded from the local config file. """ - def load_from_file(ctx, file_path: Path) -> list[PluginMetadata]: + def load_from_file(ctx, file_path: Path) -> list["PluginMetadata"]: + from ape.plugins._utils import PluginMetadata + from ape.utils.misc import load_config + if file_path.is_dir(): name_options = ( "ape-config.yaml", @@ -55,6 +51,8 @@ def load_from_file(ctx, file_path: Path) -> list[PluginMetadata]: return [] def callback(ctx, param, value: tuple[str]): + from ape.plugins._utils import PluginMetadata + res = [] if not value: ctx.obj.abort("You must give at least one requirement to install.") @@ -93,6 +91,8 @@ def upgrade_option(help: str = "", **kwargs): def _display_all_callback(ctx, param, value): + from ape.plugins._utils import PluginType + return ( (PluginType.CORE, PluginType.INSTALLED, PluginType.THIRD_PARTY, PluginType.AVAILABLE) if value @@ -112,6 +112,8 @@ def _display_all_callback(ctx, param, value): help="Display all plugins installed and available (including Core)", ) def _list(cli_ctx, to_display): + from ape.plugins._utils import PluginMetadataList, PluginType + include_available = PluginType.AVAILABLE in to_display metadata = PluginMetadataList.load(cli_ctx.plugin_manager, include_available=include_available) if output := metadata.to_str(include=to_display): @@ -128,7 +130,7 @@ def _list(cli_ctx, to_display): @plugins_argument() @skip_confirmation_option("Don't ask for confirmation to install the plugins") @upgrade_option(help="Upgrade the plugin to the newest available version") -def install(cli_ctx, plugins: list[PluginMetadata], skip_confirmation: bool, upgrade: bool): +def install(cli_ctx, plugins: list["PluginMetadata"], skip_confirmation: bool, upgrade: bool): """Install plugins""" failures_occurred = False @@ -170,6 +172,7 @@ def install(cli_ctx, plugins: list[PluginMetadata], skip_confirmation: bool, upg @skip_confirmation_option("Don't ask for confirmation to install the plugins") def uninstall(cli_ctx, plugins, skip_confirmation): """Uninstall plugins""" + from ape.plugins._utils import ModifyPluginResultHandler failures_occurred = False did_warn_about_version = False @@ -217,6 +220,7 @@ def update(): """ Update Ape and all plugins to the next version """ + from ape.plugins._utils import ape_version _change_version(ape_version.next_version_range) @@ -249,6 +253,8 @@ def _install(name, spec, exit_on_fail: bool = True) -> int: Returns: The process return-code. """ + from ape.plugins._utils import PIP_COMMAND + arguments = [*PIP_COMMAND, "install", f"{name}{spec}", "--quiet"] # Run the installation process and capture output for error checking @@ -281,6 +287,8 @@ def _change_version(spec: str): # This will also update core Ape. # NOTE: It is possible plugins may depend on each other and may update in # an order causing some error codes to pop-up, so we ignore those for now. + from ape.plugins._utils import get_plugin_dists + plugin_retcode = 0 for plugin in get_plugin_dists(): logger.info(f"Updating {plugin} ...") From 50299ab7bc1f40d97ce2b7a72b2792b210f8348c Mon Sep 17 00:00:00 2001 From: antazoey Date: Fri, 1 Nov 2024 17:59:34 -0500 Subject: [PATCH 04/15] perf: use `p1.relative(p2)` when it makes sense (#2369) --- src/ape/managers/project.py | 6 ++---- src/ape/pytest/coverage.py | 4 ++-- src/ape/utils/os.py | 12 ++++++------ src/ape_pm/compiler.py | 3 +-- tests/functional/test_compilers.py | 2 +- tests/functional/utils/test_os.py | 15 --------------- 6 files changed, 12 insertions(+), 30 deletions(-) diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index 2f6f658945..1b154a573c 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -43,7 +43,7 @@ def _path_to_source_id(path: Path, root_path: Path) -> str: - return f"{get_relative_path(path.absolute(), root_path.absolute())}" + return f"{path.relative_to(root_path)}" class SourceManager(BaseManager): @@ -495,9 +495,7 @@ def _compile( ): self._compile_contracts(needs_compile) - src_ids = [ - f"{get_relative_path(Path(p).absolute(), self.project.path)}" for p in path_ls_final - ] + src_ids = [f"{Path(p).relative_to(self.project.path)}" for p in path_ls_final] for contract_type in (self.project.manifest.contract_types or {}).values(): if contract_type.source_id and contract_type.source_id in src_ids: yield ContractContainer(contract_type) diff --git a/src/ape/pytest/coverage.py b/src/ape/pytest/coverage.py index 768c385492..eebe47c670 100644 --- a/src/ape/pytest/coverage.py +++ b/src/ape/pytest/coverage.py @@ -7,7 +7,7 @@ from ape.logging import logger from ape.utils.basemodel import ManagerAccessMixin from ape.utils.misc import get_current_timestamp_ms -from ape.utils.os import get_full_extension, get_relative_path +from ape.utils.os import get_full_extension from ape.utils.trace import parse_coverage_tables if TYPE_CHECKING: @@ -92,7 +92,7 @@ def cover( self, src_path: Path, pcs: Iterable[int], inc_fn_hits: bool = True ) -> tuple[set[int], list[str]]: if hasattr(self.project, "path"): - source_id = str(get_relative_path(src_path.absolute(), self.project.path)) + source_id = f"{src_path.relative_to(self.project.path)}" else: source_id = str(src_path) diff --git a/src/ape/utils/os.py b/src/ape/utils/os.py index f66d9736c1..bc6e3f37dd 100644 --- a/src/ape/utils/os.py +++ b/src/ape/utils/os.py @@ -34,7 +34,12 @@ def get_relative_path(target: Path, anchor: Path) -> Path: Compute the relative path of ``target`` relative to ``anchor``, which may or may not share a common ancestor. - **NOTE**: Both paths must be absolute. + **NOTE ON PERFORMANCE**: Both paths must be absolute to + use this method. If you know both methods are absolute, + this method is a performance boost. If you have to first + call `.absolute()` on the paths, use + `target.relative_to(anchor)` instead; as it will be + faster in that case. Args: target (pathlib.Path): The path we are interested in. @@ -43,11 +48,6 @@ def get_relative_path(target: Path, anchor: Path) -> Path: Returns: pathlib.Path: The new path to the target path from the anchor path. """ - if not target.is_absolute(): - raise ValueError("'target' must be an absolute path.") - if not anchor.is_absolute(): - raise ValueError("'anchor' must be an absolute path.") - # Calculate common prefix length common_parts = 0 for target_part, anchor_part in zip(target.parts, anchor.parts): diff --git a/src/ape_pm/compiler.py b/src/ape_pm/compiler.py index ebd5ea97ee..98a4d5c377 100644 --- a/src/ape_pm/compiler.py +++ b/src/ape_pm/compiler.py @@ -11,7 +11,6 @@ from ape.api.compiler import CompilerAPI from ape.exceptions import CompilerError, ContractLogicError from ape.logging import logger -from ape.utils.os import get_relative_path if TYPE_CHECKING: from ape.managers.project import ProjectManager @@ -41,7 +40,7 @@ def compile( ) -> Iterator[ContractType]: project = project or self.local_project source_ids = { - p: f"{get_relative_path(p, project.path.absolute())}" if p.is_absolute() else str(p) + p: f"{p.relative_to(project.path)}" if p.is_absolute() else str(p) for p in contract_filepaths } logger.info(f"Compiling {', '.join(source_ids.values())}.") diff --git a/tests/functional/test_compilers.py b/tests/functional/test_compilers.py index 58db5f44b8..5e9ed7ac2c 100644 --- a/tests/functional/test_compilers.py +++ b/tests/functional/test_compilers.py @@ -77,7 +77,7 @@ def test_compile(compilers, project_with_contract, factory): Testing both stringified paths and path-object paths. """ path = next(iter(project_with_contract.sources.paths)) - actual = compilers.compile((factory(path),)) + actual = compilers.compile((factory(path),), project=project_with_contract) contract_name = path.stem assert contract_name in [x.name for x in actual] diff --git a/tests/functional/utils/test_os.py b/tests/functional/utils/test_os.py index 0acca31efc..5aa5c34785 100644 --- a/tests/functional/utils/test_os.py +++ b/tests/functional/utils/test_os.py @@ -23,21 +23,6 @@ def test_get_relative_path_from_project(): assert actual == expected -def test_get_relative_path_given_relative_path(): - relative_script_path = Path("../deploy.py") - with pytest.raises(ValueError) as err: - get_relative_path(relative_script_path, _TEST_DIRECTORY_PATH) - - assert str(err.value) == "'target' must be an absolute path." - - relative_project_path = Path("../This/is/a/test") - - with pytest.raises(ValueError) as err: - get_relative_path(_TEST_FILE_PATH, relative_project_path) - - assert str(err.value) == "'anchor' must be an absolute path." - - def test_get_relative_path_same_path(): actual = get_relative_path(_TEST_FILE_PATH, _TEST_FILE_PATH) assert actual == Path() From 75e0d8ec654004042173c0cb3e178bc6153bd358 Mon Sep 17 00:00:00 2001 From: antazoey Date: Mon, 4 Nov 2024 07:38:52 -0600 Subject: [PATCH 05/15] feat: Add `--code` option to `ape console` (#2370) --- docs/userguides/console.md | 15 ++++++++++ src/ape_console/_cli.py | 40 +++++++++++++++++++++++---- tests/integration/cli/test_console.py | 12 ++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/docs/userguides/console.md b/docs/userguides/console.md index b9d8690d94..27b6cf62a2 100644 --- a/docs/userguides/console.md +++ b/docs/userguides/console.md @@ -164,3 +164,18 @@ Out[3]: '0.00040634 ETH' In [4]: %bal 0xE3747e6341E0d3430e6Ea9e2346cdDCc2F8a4b5b Out[4]: '0.00040634 ETH' ``` + +## Executing Code + +You can also use the `ape console` to execute programs directly from strings. +This is similar to the `python -c|--code` option except it will display the output cell. +Anything available in `ape console` is also available in `ape console --code`. + +```shell +ape console -c 'project.name' +Out[1]: 'my-project' +ape console -c 'x = 3\nx + 1' +Out[1]: 4 +ape console -c 'networks.active_provider.name' +Out[1]: 'test' +``` diff --git a/src/ape_console/_cli.py b/src/ape_console/_cli.py index 07855986d1..c3eef924fa 100644 --- a/src/ape_console/_cli.py +++ b/src/ape_console/_cli.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional, cast import click +from IPython import InteractiveShell from ape.cli.commands import ConnectedProviderCommand from ape.cli.options import ape_cli_context, project_option @@ -21,6 +22,18 @@ CONSOLE_EXTRAS_FILENAME = "ape_console_extras.py" +def _code_callback(ctx, param, value) -> list[str]: + if not value: + return value + + # NOTE: newlines are escaped in code automatically, so we + # need to de-escape them. Any actually escaped newlines + # will still be escaped. + value = value.replace("\\n", "\n").replace("\\t", "\t").replace("\\b", "\b") + + return value.splitlines() + + @click.command( cls=ConnectedProviderCommand, short_help="Load the console", @@ -28,10 +41,11 @@ ) @ape_cli_context() @project_option(hidden=True) # Hidden as mostly used for test purposes. -def cli(cli_ctx, project): +@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.""" verbose = cli_ctx.logger.level == logging.DEBUG - return console(project=project, verbose=verbose) + return console(project=project, verbose=verbose, code=code) def import_extras_file(file_path) -> ModuleType: @@ -95,6 +109,7 @@ def console( verbose: bool = False, extra_locals: Optional[dict] = None, embed: bool = False, + code: Optional[list[str]] = None, ): import IPython from IPython.terminal.ipapp import Config as IPythonConfig @@ -149,16 +164,24 @@ def console( # Required for click.testing.CliRunner support. embed = True - _launch_console(namespace, ipy_config, embed, banner) + _launch_console(namespace, ipy_config, embed, banner, code=code) -def _launch_console(namespace: dict, ipy_config: "IPythonConfig", embed: bool, banner: str): +def _launch_console( + namespace: dict, + ipy_config: "IPythonConfig", + embed: bool, + banner: str, + code: Optional[list[str]], +): import IPython from ape_console.config import ConsoleConfig ipython_kwargs = {"user_ns": namespace, "config": ipy_config} - if embed: + if code: + _execute_code(code, **ipython_kwargs) + elif embed: IPython.embed(**ipython_kwargs, colors="Neutral", banner1=banner) else: ipy_config.TerminalInteractiveShell.colors = "Neutral" @@ -169,3 +192,10 @@ def _launch_console(namespace: dict, ipy_config: "IPythonConfig", embed: bool, b ipy_config.InteractiveShellApp.extensions.extend(console_config.plugins) IPython.start_ipython(**ipython_kwargs, argv=()) + + +def _execute_code(code: list[str], **ipython_kwargs): + shell = InteractiveShell.instance(**ipython_kwargs) + # NOTE: Using `store_history=True` just so the cell IDs are accurate. + for line in code: + shell.run_cell(line, store_history=True) diff --git a/tests/integration/cli/test_console.py b/tests/integration/cli/test_console.py index fac1c5ec0f..171ac3311b 100644 --- a/tests/integration/cli/test_console.py +++ b/tests/integration/cli/test_console.py @@ -310,3 +310,15 @@ def test_console_natspecs(integ_project, solidity_contract_type, console_runner) assert all(ln in actual for ln in expected_method.splitlines()) assert all(ln in actual for ln in expected_event.splitlines()) + + +@skip_projects_except("with-contracts") +def test_console_code(integ_project, mocker, console_runner): + """ + Testing the -c | --code option. + """ + result = console_runner.invoke( + "--project", f"{integ_project.path}", "--code", "chain\nx = 3\nx + 1" + ) + expected = "Out[1]: \nOut[3]: 4\n" + assert result.output == expected From f833e468e0818a33d30cddd4f441b55932ce2785 Mon Sep 17 00:00:00 2001 From: antazoey Date: Mon, 4 Nov 2024 08:53:53 -0600 Subject: [PATCH 06/15] feat: lookup network in evmchains; plugin-less networks, adhoc networks w/ correct name (#2328) --- docs/userguides/networks.md | 21 ++++++++++-- src/ape/api/networks.py | 34 +++++++++++++++---- src/ape/managers/networks.py | 33 ++++++++++++++---- src/ape/managers/project.py | 1 - src/ape_ethereum/provider.py | 21 +++++++++++- tests/functional/geth/test_network_manager.py | 14 ++++++++ 6 files changed, 105 insertions(+), 19 deletions(-) diff --git a/docs/userguides/networks.md b/docs/userguides/networks.md index ae6237b0fb..9e91b89db6 100644 --- a/docs/userguides/networks.md +++ b/docs/userguides/networks.md @@ -1,8 +1,9 @@ # Networks When interacting with a blockchain, you will have to select an ecosystem (e.g. Ethereum, Arbitrum, or Fantom), a network (e.g. Mainnet or Sepolia) and a provider (e.g. Eth-Tester, Node (Geth), or Alchemy). -Networks are part of ecosystems and typically defined in plugins. -For example, the `ape-ethereum` plugin comes with Ape and can be used for handling EVM-like behavior. +The `ape-ethereum` ecosystem and network(s) plugin comes with Ape and can be used for handling EVM-like behavior. +Networks are part of ecosystems and typically defined in plugins or custom-network configurations. +However, Ape works out-of-the-box (in a limited way) with any network defined in the [evmchains](https://github.com/ApeWorX/evmchains) library. ## Selecting a Network @@ -25,7 +26,7 @@ ape test --network ethereum:local:foundry ape console --network arbitrum:testnet:alchemy # NOTICE: All networks, even from other ecosystems, use this. ``` -To see all possible values for `--network`, run the command: +To see all networks that work with the `--network` flag (besides those _only_ defined in `evmchains`), run the command: ```shell ape networks list @@ -100,6 +101,20 @@ ape networks list In the remainder of this guide, any example below using Ethereum, you can replace with an L2 ecosystem's name and network combination. +## evmchains Networks + +If a network is in the [evmchains](https://github.com/ApeWorX/evmchains) library, it will work in Ape automatically, even without a plugin or any custom configuration for that network. + +```shell +ape console --network moonbeam +``` + +This works because the `moonbeam` network data is available in the `evmchains` library, and Ape is able to look it up. + +```{warning} +Support for networks from evm-chains alone may be limited and require additional configuration to work in production use-cases. +``` + ## Custom Network Connection You can add custom networks to Ape without creating a plugin. diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index 5f965428f1..07c249135c 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -12,6 +12,7 @@ ) from eth_pydantic_types import HexBytes from eth_utils import keccak, to_int +from evmchains import PUBLIC_CHAIN_META from pydantic import model_validator from ape.exceptions import ( @@ -109,7 +110,7 @@ def data_folder(self) -> Path: """ return self.config_manager.DATA_FOLDER / self.name - @cached_property + @property def custom_network(self) -> "NetworkAPI": """ A :class:`~ape.api.networks.NetworkAPI` for custom networks where the @@ -125,13 +126,11 @@ def custom_network(self) -> "NetworkAPI": if ethereum_class is None: raise NetworkError("Core Ethereum plugin missing.") - request_header = self.config_manager.REQUEST_HEADER - init_kwargs = {"name": "ethereum", "request_header": request_header} - ethereum = ethereum_class(**init_kwargs) # type: ignore + init_kwargs = {"name": "ethereum"} + evm_ecosystem = ethereum_class(**init_kwargs) # type: ignore return NetworkAPI( name="custom", - ecosystem=ethereum, - request_header=request_header, + ecosystem=evm_ecosystem, _default_provider="node", _is_custom=True, ) @@ -301,6 +300,11 @@ def networks(self) -> dict[str, "NetworkAPI"]: network_api._is_custom = True networks[net_name] = network_api + # Add any remaining networks from EVM chains here (but don't override). + # NOTE: Only applicable to EVM-based ecosystems, of course. + # Otherwise, this is a no-op. + networks = {**self._networks_from_evmchains, **networks} + return networks @cached_property @@ -311,6 +315,17 @@ def _networks_from_plugins(self) -> dict[str, "NetworkAPI"]: if ecosystem_name == self.name } + @cached_property + def _networks_from_evmchains(self) -> dict[str, "NetworkAPI"]: + # NOTE: Purposely exclude plugins here so we also prefer plugins. + return { + network_name: create_network_type(data["chainId"], data["chainId"])( + name=network_name, ecosystem=self + ) + for network_name, data in PUBLIC_CHAIN_META.get(self.name, {}).items() + if network_name not in self._networks_from_plugins + } + def __post_init__(self): if len(self.networks) == 0: raise NetworkError("Must define at least one network in ecosystem") @@ -1057,7 +1072,6 @@ def providers(self): # -> dict[str, Partial[ProviderAPI]] Returns: dict[str, partial[:class:`~ape.api.providers.ProviderAPI`]] """ - from ape.plugins._utils import clean_plugin_name providers = {} @@ -1089,6 +1103,12 @@ def providers(self): # -> dict[str, Partial[ProviderAPI]] network=self, ) + # Any EVM-chain works with node provider. + if "node" not in providers and self.name in self.ecosystem._networks_from_evmchains: + # NOTE: Arbitrarily using sepolia to access the Node class. + node_provider_cls = self.network_manager.ethereum.sepolia.get_provider("node").__class__ + providers["node"] = partial(node_provider_cls, name="node", network=self) + return providers def _get_plugin_providers(self): diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index 8297ce43a3..a30c63b06e 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -2,6 +2,8 @@ from functools import cached_property from typing import TYPE_CHECKING, Optional, Union +from evmchains import PUBLIC_CHAIN_META + from ape.api.networks import EcosystemAPI, NetworkAPI, ProviderContextManager from ape.exceptions import EcosystemNotFoundError, NetworkError, NetworkNotFoundError from ape.managers.base import BaseManager @@ -53,7 +55,6 @@ def active_provider(self) -> Optional["ProviderAPI"]: """ The currently connected provider if one exists. Otherwise, returns ``None``. """ - return self._active_provider @active_provider.setter @@ -164,7 +165,6 @@ def ecosystem_names(self) -> set[str]: """ The set of all ecosystem names in ``ape``. """ - return set(self.ecosystems) @property @@ -236,7 +236,8 @@ def ecosystems(self) -> dict[str, EcosystemAPI]: existing_cls = plugin_ecosystems[base_ecosystem_name] ecosystem_cls = existing_cls.model_copy( - update={"name": ecosystem_name}, cache_clear=("_networks_from_plugins",) + update={"name": ecosystem_name}, + cache_clear=("_networks_from_plugins", "_networks_from_evmchains"), ) plugin_ecosystems[ecosystem_name] = ecosystem_cls @@ -437,10 +438,29 @@ def get_ecosystem(self, ecosystem_name: str) -> EcosystemAPI: :class:`~ape.api.networks.EcosystemAPI` """ - if ecosystem_name not in self.ecosystem_names: - raise EcosystemNotFoundError(ecosystem_name, options=self.ecosystem_names) + if ecosystem_name in self.ecosystem_names: + return self.ecosystems[ecosystem_name] - return self.ecosystems[ecosystem_name] + 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 "symbol" not in native_currency: + continue + + 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) + + raise EcosystemNotFoundError(ecosystem_name, options=self.ecosystem_names) def get_provider_from_choice( self, @@ -548,7 +568,6 @@ def parse_network_choice( Returns: :class:`~api.api.networks.ProviderContextManager` """ - provider = self.get_provider_from_choice( network_choice=network_choice, provider_settings=provider_settings ) diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index 1b154a573c..9fd69439db 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -1940,7 +1940,6 @@ def reconfigure(self, **overrides): self._config_override = overrides _ = self.config - self.account_manager.test_accounts.reset() def extract_manifest(self) -> PackageManifest: diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index c1ff49e704..8359729a09 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -16,7 +16,7 @@ from eth_pydantic_types import HexBytes from eth_typing import BlockNumber, HexStr from eth_utils import add_0x_prefix, is_hex, to_hex -from evmchains import get_random_rpc +from evmchains import PUBLIC_CHAIN_META, get_random_rpc from pydantic.dataclasses import dataclass from requests import HTTPError from web3 import HTTPProvider, IPCProvider, Web3 @@ -1524,9 +1524,16 @@ def _complete_connect(self): for option in ("earliest", "latest"): try: block = self.web3.eth.get_block(option) # type: ignore[arg-type] + except ExtraDataLengthError: is_likely_poa = True break + + except Exception: + # Some chains are "light" and we may not be able to detect + # if it need PoA middleware. + continue + else: is_likely_poa = ( "proofOfAuthorityData" in block @@ -1540,6 +1547,18 @@ def _complete_connect(self): self.network.verify_chain_id(chain_id) + # Correct network name, if using custom-URL approach. + if self.network.name == "custom": + for ecosystem_name, network in PUBLIC_CHAIN_META.items(): + for network_name, meta in network.items(): + if "chainId" not in meta or meta["chainId"] != chain_id: + continue + + # Network found. + self.network.name = network_name + self.network.ecosystem.name = ecosystem_name + break + def disconnect(self): self._call_trace_approach = None self._web3 = None diff --git a/tests/functional/geth/test_network_manager.py b/tests/functional/geth/test_network_manager.py index 18d5274f6e..8c2d5986aa 100644 --- a/tests/functional/geth/test_network_manager.py +++ b/tests/functional/geth/test_network_manager.py @@ -37,3 +37,17 @@ def test_fork_upstream_provider(networks, mock_geth_sepolia, geth_provider, mock geth_provider.provider_settings["uri"] = orig else: del geth_provider.provider_settings["uri"] + + +@geth_process_test +@pytest.mark.parametrize( + "connection_str", ("moonbeam:moonriver", "https://moonriver.api.onfinality.io/public") +) +def test_parse_network_choice_evmchains(networks, connection_str): + """ + Show we can (without having a plugin installed) connect to a network + that evm-chains knows about. + """ + with networks.parse_network_choice(connection_str) as moon_provider: + assert moon_provider.network.name == "moonriver" + assert moon_provider.network.ecosystem.name == "moonbeam" From b71c810568ab321a3c7d9bb267b95e503d5bfff7 Mon Sep 17 00:00:00 2001 From: antazoey Date: Mon, 4 Nov 2024 10:23:12 -0600 Subject: [PATCH 07/15] perf: make contracts load faster (#2371) --- src/ape/contracts/__init__.py | 6 ++- src/ape/contracts/base.py | 73 +++++++++++++++++--------------- src/ape_node/provider.py | 2 +- tests/functional/test_project.py | 6 ++- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/ape/contracts/__init__.py b/src/ape/contracts/__init__.py index 8ebd5e04f2..535514e7af 100644 --- a/src/ape/contracts/__init__.py +++ b/src/ape/contracts/__init__.py @@ -1,4 +1,8 @@ -from .base import ContractContainer, ContractEvent, ContractInstance, ContractLog, ContractNamespace +def __getattr__(name: str): + import ape.contracts.base as module + + return getattr(module, name) + __all__ = [ "ContractContainer", diff --git a/src/ape/contracts/base.py b/src/ape/contracts/base.py index 9c73fb8158..3c6be64c0b 100644 --- a/src/ape/contracts/base.py +++ b/src/ape/contracts/base.py @@ -7,14 +7,10 @@ from typing import TYPE_CHECKING, Any, Optional, Union import click -import pandas as pd from eth_pydantic_types import HexBytes from eth_utils import to_hex -from ethpm_types.abi import EventABI, MethodABI -from ethpm_types.contract_type import ABI_W_SELECTOR_T, ContractType -from IPython.lib.pretty import for_type +from ethpm_types.abi import EventABI -from ape.api.accounts import AccountAPI from ape.api.address import Address, BaseAddress from ape.api.query import ( ContractCreation, @@ -34,7 +30,6 @@ MissingDeploymentBytecodeError, ) from ape.logging import get_rich_console, logger -from ape.types.address import AddressType from ape.types.events import ContractLog, LogFilter, MockContractLog from ape.utils.abi import StructParser, _enrich_natspec from ape.utils.basemodel import ( @@ -49,9 +44,12 @@ from ape.utils.misc import log_instead_of_fail if TYPE_CHECKING: - from ethpm_types.abi import ConstructorABI, ErrorABI + from ethpm_types.abi import ConstructorABI, ErrorABI, MethodABI + from ethpm_types.contract_type import ABI_W_SELECTOR_T, ContractType + from pandas import DataFrame from ape.api.transactions import ReceiptAPI, TransactionAPI + from ape.types.address import AddressType class ContractConstructor(ManagerAccessMixin): @@ -90,7 +88,7 @@ def serialize_transaction(self, *args, **kwargs) -> "TransactionAPI": def __call__(self, private: bool = False, *args, **kwargs) -> "ReceiptAPI": txn = self.serialize_transaction(*args, **kwargs) - if "sender" in kwargs and isinstance(kwargs["sender"], AccountAPI): + if "sender" in kwargs and hasattr(kwargs["sender"], "call"): sender = kwargs["sender"] return sender.call(txn, **kwargs) elif "sender" not in kwargs and self.account_manager.default_sender is not None: @@ -104,7 +102,7 @@ def __call__(self, private: bool = False, *args, **kwargs) -> "ReceiptAPI": class ContractCall(ManagerAccessMixin): - def __init__(self, abi: MethodABI, address: AddressType) -> None: + def __init__(self, abi: "MethodABI", address: "AddressType") -> None: super().__init__() self.abi = abi self.address = address @@ -140,9 +138,9 @@ def __call__(self, *args, **kwargs) -> Any: class ContractMethodHandler(ManagerAccessMixin): contract: "ContractInstance" - abis: list[MethodABI] + abis: list["MethodABI"] - def __init__(self, contract: "ContractInstance", abis: list[MethodABI]) -> None: + def __init__(self, contract: "ContractInstance", abis: list["MethodABI"]) -> None: super().__init__() self.contract = contract self.abis = abis @@ -320,7 +318,7 @@ def estimate_gas_cost(self, *args, **kwargs) -> int: return self.transact.estimate_gas_cost(*arguments, **kwargs) -def _select_method_abi(abis: list[MethodABI], args: Union[tuple, list]) -> MethodABI: +def _select_method_abi(abis: list["MethodABI"], args: Union[tuple, list]) -> "MethodABI": args = args or [] selected_abi = None for abi in abis: @@ -335,13 +333,10 @@ def _select_method_abi(abis: list[MethodABI], args: Union[tuple, list]) -> Metho class ContractTransaction(ManagerAccessMixin): - abi: MethodABI - address: AddressType - - def __init__(self, abi: MethodABI, address: AddressType) -> None: + def __init__(self, abi: "MethodABI", address: "AddressType") -> None: super().__init__() - self.abi = abi - self.address = address + self.abi: "MethodABI" = abi + self.address: "AddressType" = address @log_instead_of_fail(default="") def __repr__(self) -> str: @@ -362,7 +357,7 @@ def __call__(self, *args, **kwargs) -> "ReceiptAPI": txn = self.serialize_transaction(*args, **kwargs) private = kwargs.get("private", False) - if "sender" in kwargs and isinstance(kwargs["sender"], AccountAPI): + if "sender" in kwargs and hasattr(kwargs["sender"], "call"): return kwargs["sender"].call(txn, **kwargs) txn = self.provider.prepare_transaction(txn) @@ -441,6 +436,7 @@ def _as_transaction(self, *args) -> ContractTransaction: ) +# TODO: In Ape 0.9 - make not a BaseModel - no reason to. class ContractEvent(BaseInterfaceModel): """ The types of events on a :class:`~ape.contracts.base.ContractInstance`. @@ -616,7 +612,7 @@ def query( stop_block: Optional[int] = None, step: int = 1, engine_to_use: Optional[str] = None, - ) -> pd.DataFrame: + ) -> "DataFrame": """ Iterate through blocks for log events @@ -635,6 +631,8 @@ def query( Returns: pd.DataFrame """ + # perf: pandas import is really slow. Avoid importing at module level. + import pandas as pd if start_block < 0: start_block = self.chain_manager.blocks.height + start_block @@ -800,7 +798,7 @@ def poll_logs( class ContractTypeWrapper(ManagerAccessMixin): - contract_type: ContractType + contract_type: "ContractType" base_path: Optional[Path] = None @property @@ -812,7 +810,7 @@ def selector_identifiers(self) -> dict[str, str]: return self.contract_type.selector_identifiers @property - def identifier_lookup(self) -> dict[str, ABI_W_SELECTOR_T]: + def identifier_lookup(self) -> dict[str, "ABI_W_SELECTOR_T"]: """ Provides a mapping of method, error, and event selector identifiers to ABI Types. @@ -898,6 +896,9 @@ def repr_pretty_for_assignment(cls, *args, **kwargs): info = _get_info() error_type.info = error_type.__doc__ = info # type: ignore if info: + # perf: Avoid forcing everyone to import from IPython. + from IPython.lib.pretty import for_type + error_type._repr_pretty_ = repr_pretty_for_assignment # type: ignore # Register the dynamically-created type with IPython so it integrates. @@ -922,8 +923,8 @@ class ContractInstance(BaseAddress, ContractTypeWrapper): def __init__( self, - address: AddressType, - contract_type: ContractType, + address: "AddressType", + contract_type: "ContractType", txn_hash: Optional[Union[str, HexBytes]] = None, ) -> None: super().__init__() @@ -957,7 +958,9 @@ def __call__(self, *args, **kwargs) -> "ReceiptAPI": return super().__call__(*args, **kwargs) @classmethod - def from_receipt(cls, receipt: "ReceiptAPI", contract_type: ContractType) -> "ContractInstance": + def from_receipt( + cls, receipt: "ReceiptAPI", contract_type: "ContractType" + ) -> "ContractInstance": """ Create a contract instance from the contract deployment receipt. """ @@ -997,7 +1000,7 @@ def __repr__(self) -> str: return f"<{contract_name} {self.address}>" @property - def address(self) -> AddressType: + def address(self) -> "AddressType": """ The address of the contract. @@ -1009,7 +1012,7 @@ def address(self) -> AddressType: @cached_property def _view_methods_(self) -> dict[str, ContractCallHandler]: - view_methods: dict[str, list[MethodABI]] = dict() + view_methods: dict[str, list["MethodABI"]] = dict() for abi in self.contract_type.view_methods: if abi.name in view_methods: @@ -1028,7 +1031,7 @@ def _view_methods_(self) -> dict[str, ContractCallHandler]: @cached_property def _mutable_methods_(self) -> dict[str, ContractTransactionHandler]: - mutable_methods: dict[str, list[MethodABI]] = dict() + mutable_methods: dict[str, list["MethodABI"]] = dict() for abi in self.contract_type.mutable_methods: if abi.name in mutable_methods: @@ -1075,7 +1078,7 @@ def call_view_method(self, method_name: str, *args, **kwargs) -> Any: else: # Didn't find anything that matches - name = self.contract_type.name or ContractType.__name__ + name = self.contract_type.name or "ContractType" raise ApeAttributeError(f"'{name}' has no attribute '{method_name}'.") def invoke_transaction(self, method_name: str, *args, **kwargs) -> "ReceiptAPI": @@ -1110,7 +1113,7 @@ def invoke_transaction(self, method_name: str, *args, **kwargs) -> "ReceiptAPI": else: # Didn't find anything that matches - name = self.contract_type.name or ContractType.__name__ + name = self.contract_type.name or "ContractType" raise ApeAttributeError(f"'{name}' has no attribute '{method_name}'.") def get_event_by_signature(self, signature: str) -> ContractEvent: @@ -1168,7 +1171,7 @@ def get_error_by_signature(self, signature: str) -> type[CustomError]: @cached_property def _events_(self) -> dict[str, list[ContractEvent]]: - events: dict[str, list[EventABI]] = {} + events: dict[str, list["EventABI"]] = {} for abi in self.contract_type.events: if abi.name in events: @@ -1339,7 +1342,7 @@ class ContractContainer(ContractTypeWrapper, ExtraAttributesMixin): contract_container = project.MyContract # Assuming there is a contract named "MyContract" """ - def __init__(self, contract_type: ContractType) -> None: + def __init__(self, contract_type: "ContractType") -> None: self.contract_type = contract_type @log_instead_of_fail(default="") @@ -1404,7 +1407,7 @@ def deployments(self): return self.chain_manager.contracts.get_deployments(self) def at( - self, address: AddressType, txn_hash: Optional[Union[str, HexBytes]] = None + self, address: "AddressType", txn_hash: Optional[Union[str, HexBytes]] = None ) -> ContractInstance: """ Get a contract at the given address. @@ -1473,7 +1476,7 @@ def deploy(self, *args, publish: bool = False, **kwargs) -> ContractInstance: if kwargs.get("value") and not self.contract_type.constructor.is_payable: raise MethodNonPayableError("Sending funds to a non-payable constructor.") - if "sender" in kwargs and isinstance(kwargs["sender"], AccountAPI): + if "sender" in kwargs and hasattr(kwargs["sender"], "call"): # Handle account-related preparation if needed, such as signing receipt = self._cache_wrap(lambda: kwargs["sender"].call(txn, **kwargs)) @@ -1533,7 +1536,7 @@ def declare(self, *args, **kwargs) -> "ReceiptAPI": transaction = self.provider.network.ecosystem.encode_contract_blueprint( self.contract_type, *args, **kwargs ) - if "sender" in kwargs and isinstance(kwargs["sender"], AccountAPI): + if "sender" in kwargs and hasattr(kwargs["sender"], "call"): return kwargs["sender"].call(transaction) receipt = self.provider.send_transaction(transaction) diff --git a/src/ape_node/provider.py b/src/ape_node/provider.py index 95bd54d2b7..aa7fb0f2ee 100644 --- a/src/ape_node/provider.py +++ b/src/ape_node/provider.py @@ -208,7 +208,7 @@ def disconnect(self): def _clean(self): if self._data_dir.is_dir(): - shutil.rmtree(self._data_dir) + shutil.rmtree(self._data_dir, ignore_errors=True) # dir must exist when initializing chain. self._data_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/functional/test_project.py b/tests/functional/test_project.py index bb0ee3de41..b432fc1b94 100644 --- a/tests/functional/test_project.py +++ b/tests/functional/test_project.py @@ -673,8 +673,12 @@ class TestProject: def test_init(self, with_dependencies_project_path): # Purpose not using `project_with_contracts` fixture. project = Project(with_dependencies_project_path) - project.manifest_path.unlink(missing_ok=True) assert project.path == with_dependencies_project_path + project.manifest_path.unlink(missing_ok=True) + + # Re-init to show it doesn't create the manifest file. + project = Project(with_dependencies_project_path) + # Manifest should have been created by default. assert not project.manifest_path.is_file() From 0a227e188445835735dfccefccf246501364a674 Mon Sep 17 00:00:00 2001 From: antazoey Date: Mon, 4 Nov 2024 10:48:40 -0600 Subject: [PATCH 08/15] perf: make `ape run --help` faster (#2364) --- src/ape_run/_cli.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/ape_run/_cli.py b/src/ape_run/_cli.py index 94e30d361d..6dc0153295 100644 --- a/src/ape_run/_cli.py +++ b/src/ape_run/_cli.py @@ -8,19 +8,18 @@ from typing import Any, Union import click -from click import Command, Context, Option 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 as access -from ape.utils.os import get_relative_path, use_temp_sys_path -from ape_console._cli import console @contextmanager def use_scripts_sys_path(path: Path): + # perf: avoid importing at top of module so `--help` is faster. + from ape.utils.os import use_temp_sys_path + # First, ensure there is not an existing scripts module. scripts = sys.modules.get("scripts") if scripts: @@ -70,7 +69,9 @@ def __init__(self, *args, **kwargs): self._command_called = None self._has_warned_missing_hook: set[Path] = set() - def invoke(self, ctx: Context) -> Any: + def invoke(self, ctx: click.Context) -> Any: + from ape.utils.basemodel import ManagerAccessMixin as access + try: return super().invoke(ctx) except Exception as err: @@ -95,7 +96,8 @@ 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, access.local_project.path) + scripts_folder = Path.cwd() / "scripts" + relative_filepath = filepath.relative_to(scripts_folder) # First load the code module by compiling it # NOTE: This does not execute the module @@ -122,14 +124,14 @@ def _get_command(self, filepath: Path) -> Union[click.Command, click.Group, None self._namespace[filepath.stem] = cli_ns cli_obj = cli_ns["cli"] - if not isinstance(cli_obj, Command): + if not isinstance(cli_obj, click.Command): logger.warning("Found `cli()` method but it is not a click command.") return None params = [getattr(x, "name", None) for x in cli_obj.params] if "verbosity" not in params: option_kwargs = _create_verbosity_kwargs() - option = Option(_VERBOSITY_VALUES, **option_kwargs) + option = click.Option(_VERBOSITY_VALUES, **option_kwargs) cli_obj.params.append(option) cli_obj.name = filepath.stem if cli_obj.name in ("cli", "", None) else cli_obj.name @@ -175,13 +177,16 @@ def call(): @property def commands(self) -> dict[str, Union[click.Command, click.Group]]: - if not access.local_project.scripts_folder.is_dir(): + # perf: Don't reference `.local_project.scripts_folder` here; + # it's too slow when doing just doing `--help`. + scripts_folder = Path.cwd() / "scripts" + if not scripts_folder.is_dir(): return {} - return self._get_cli_commands(access.local_project.scripts_folder) + return self._get_cli_commands(scripts_folder) def _get_cli_commands(self, base_path: Path) -> dict: - commands: dict[str, Command] = {} + commands: dict[str, click.Command] = {} for filepath in base_path.iterdir(): if filepath.stem.startswith("_"): @@ -194,6 +199,7 @@ def _get_cli_commands(self, base_path: Path) -> dict: subcommands = self._get_cli_commands(filepath) for subcommand in subcommands.values(): group.add_command(subcommand) + commands[filepath.stem] = group if filepath.suffix == ".py": @@ -223,6 +229,8 @@ def result_callback(self, result, interactive: bool): # type: ignore[override] return result def _launch_console(self): + from ape.utils.basemodel import ManagerAccessMixin as access + trace = inspect.trace() trace_frames = [ x for x in trace if x.filename.startswith(str(access.local_project.scripts_folder)) @@ -247,6 +255,8 @@ def _launch_console(self): if frame: del frame + from ape_console._cli import console + return console(project=access.local_project, extra_locals=extra_locals, embed=True) From 6a8a73728cfa5543b6ec2a49f49b5d5666eb97f7 Mon Sep 17 00:00:00 2001 From: antazoey Date: Mon, 4 Nov 2024 11:06:04 -0600 Subject: [PATCH 09/15] fix: recursion error when a bad URI was configured in `node:` (#2372) --- src/ape_ethereum/provider.py | 9 +++++---- tests/functional/geth/test_provider.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index 8359729a09..58bba04dde 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -1340,7 +1340,7 @@ def uri(self) -> str: else: raise TypeError(f"Not an URI: {uri}") - config = self.config.model_dump().get(self.network.ecosystem.name, None) + config: dict = self.config.get(self.network.ecosystem.name, None) if config is None: if rpc := self._get_random_rpc(): return rpc @@ -1351,7 +1351,7 @@ def uri(self) -> str: raise ProviderError(f"Please configure a URL for '{self.network_choice}'.") # Use value from config file - network_config = config.get(self.network.name) or DEFAULT_SETTINGS + network_config: dict = (config or {}).get(self.network.name) or DEFAULT_SETTINGS if "url" in network_config: raise ConfigError("Unknown provider setting 'url'. Did you mean 'uri'?") @@ -1370,10 +1370,11 @@ def uri(self) -> str: settings_uri = network_config.get(key, DEFAULT_SETTINGS["uri"]) if _is_uri(settings_uri): + # Is true if HTTP, WS, or IPC. return settings_uri - # Likely was an IPC Path (or websockets) and will connect that way. - return super().http_uri or "" + # Is not HTTP, WS, or IPC. Raise an error. + raise ConfigError(f"Invalid URI (not HTTP, WS, or IPC): {settings_uri}") @property def http_uri(self) -> Optional[str]: diff --git a/tests/functional/geth/test_provider.py b/tests/functional/geth/test_provider.py index 57cb676451..93e8c550ca 100644 --- a/tests/functional/geth/test_provider.py +++ b/tests/functional/geth/test_provider.py @@ -1,3 +1,4 @@ +import re from pathlib import Path from typing import cast @@ -16,6 +17,7 @@ from ape.exceptions import ( APINotImplementedError, BlockNotFoundError, + ConfigError, ContractLogicError, NetworkMismatchError, ProviderError, @@ -127,6 +129,23 @@ def test_uri_non_dev_and_not_configured(mocker, ethereum): assert actual == expected +def test_uri_invalid(geth_provider, project, ethereum): + settings = geth_provider.provider_settings + geth_provider.provider_settings = {} + value = "I AM NOT A URI OF ANY KIND!" + config = {"node": {"ethereum": {"local": {"uri": value}}}} + + try: + with project.temp_config(**config): + # Assert we use the config value. + expected = rf"Invalid URI \(not HTTP, WS, or IPC\): {re.escape(value)}" + with pytest.raises(ConfigError, match=expected): + _ = geth_provider.uri + + finally: + geth_provider.provider_settings = settings + + @geth_process_test def test_repr_connected(geth_provider): actual = repr(geth_provider) From 04ac2f1490172df7e6366cc1e87612bebac8888b Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 5 Nov 2024 08:48:51 -0600 Subject: [PATCH 10/15] perf: localize import for `ape console` (#2374) --- src/ape_console/_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ape_console/_cli.py b/src/ape_console/_cli.py index c3eef924fa..a3d3431cac 100644 --- a/src/ape_console/_cli.py +++ b/src/ape_console/_cli.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Optional, cast import click -from IPython import InteractiveShell from ape.cli.commands import ConnectedProviderCommand from ape.cli.options import ape_cli_context, project_option @@ -195,6 +194,8 @@ def _launch_console( def _execute_code(code: list[str], **ipython_kwargs): + from IPython import InteractiveShell + shell = InteractiveShell.instance(**ipython_kwargs) # NOTE: Using `store_history=True` just so the cell IDs are accurate. for line in code: From a84b6922f79e98e61eec5a7cc2949e3ce58ab290 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 5 Nov 2024 09:09:22 -0600 Subject: [PATCH 11/15] test: rerun random URL connection test if need-be (#2373) --- setup.py | 1 + tests/functional/geth/test_network_manager.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/setup.py b/setup.py index 932989e652..8b2f7d5e57 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ "pytest-cov>=4.0.0,<5", # Coverage analyzer plugin "pytest-mock", # For creating mocks "pytest-benchmark", # For performance tests + "pytest-rerunfailures", # For flakey tests "pytest-timeout>=2.2.0,<3", # For avoiding timing out during tests "hypothesis>=6.2.0,<7.0", # Strategy-based fuzzer "hypothesis-jsonschema==0.19.0", # JSON Schema fuzzer extension diff --git a/tests/functional/geth/test_network_manager.py b/tests/functional/geth/test_network_manager.py index 8c2d5986aa..a0949ee245 100644 --- a/tests/functional/geth/test_network_manager.py +++ b/tests/functional/geth/test_network_manager.py @@ -39,6 +39,8 @@ def test_fork_upstream_provider(networks, mock_geth_sepolia, geth_provider, mock del geth_provider.provider_settings["uri"] +# NOTE: Test is flakey because random URLs may be offline when test runs; avoid CI failure. +@pytest.mark.flaky(reruns=5) @geth_process_test @pytest.mark.parametrize( "connection_str", ("moonbeam:moonriver", "https://moonriver.api.onfinality.io/public") From 0c9e5f16e6da98fc504f65e7312c1832e94b0602 Mon Sep 17 00:00:00 2001 From: omahs <73983677+omahs@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:18:31 +0100 Subject: [PATCH 12/15] docs: fix typos (#2375) --- src/ape/api/accounts.py | 2 +- src/ape/api/providers.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ape/api/accounts.py b/src/ape/api/accounts.py index c8c357ffb6..c5e3ba1dc0 100644 --- a/src/ape/api/accounts.py +++ b/src/ape/api/accounts.py @@ -394,7 +394,7 @@ def prepare_transaction(self, txn: TransactionAPI) -> TransactionAPI: :class:`~ape.api.transactions.TransactionAPI` """ - # NOTE: Allow overriding nonce, assume user understand what this does + # NOTE: Allow overriding nonce, assume user understands what this does if txn.nonce is None: txn.nonce = self.nonce elif txn.nonce < self.nonce: diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index b0e1695f0b..559646f70e 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -60,7 +60,7 @@ class BlockAPI(BaseInterfaceModel): An abstract class representing a block and its attributes. """ - # NOTE: All fields in this class (and it's subclasses) should not be `Optional` + # NOTE: All fields in this class (and its subclasses) should not be `Optional` # except the edge cases noted below num_transactions: HexInt = 0 @@ -231,7 +231,7 @@ def connection_str(self) -> str: @abstractmethod def connect(self): """ - Connect a to a provider, such as start-up a process or create an HTTP connection. + Connect to a provider, such as start-up a process or create an HTTP connection. """ @abstractmethod @@ -352,7 +352,7 @@ def network_choice(self) -> str: def make_request(self, rpc: str, parameters: Optional[Iterable] = None) -> Any: """ Make a raw RPC request to the provider. - Advanced featues such as tracing may utilize this to by-pass unnecessary + Advanced features such as tracing may utilize this to by-pass unnecessary class-serializations. """ @@ -933,7 +933,7 @@ def auto_mine(self) -> bool: @abstractmethod def auto_mine(self) -> bool: """ - Enable or disbale automine. + Enable or disable automine. """ def _increment_call_func_coverage_hit_count(self, txn: TransactionAPI): From e31c42695032ac076fe6a041065d5f957f3b8982 Mon Sep 17 00:00:00 2001 From: antazoey Date: Fri, 8 Nov 2024 18:54:19 -0600 Subject: [PATCH 13/15] perf: `ape_node` plugin load time improvement (#2378) --- docs/userguides/developing_plugins.md | 8 ++++++++ src/ape_node/__init__.py | 20 +++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/userguides/developing_plugins.md b/docs/userguides/developing_plugins.md index 199e970cbb..f1a407cbea 100644 --- a/docs/userguides/developing_plugins.md +++ b/docs/userguides/developing_plugins.md @@ -61,6 +61,9 @@ from ape import plugins # Here, we register our provider plugin so we can use it in 'ape'. @plugins.register(plugins.ProviderPlugin) def providers(): + # NOTE: By keeping this import local, we avoid slower plugin load times. + from ape_my_plugin.provider import MyProvider + # NOTE: 'MyProvider' defined in a prior code-block. yield "ethereum", "local", MyProvider ``` @@ -69,6 +72,11 @@ This decorator hooks into ape core and ties everything together by looking for a Then, it will loop through these potential `ape` plugins and see which ones have created a plugin type registration. If the plugin type registration is found, then `ape` knows this package is a plugin and attempts to process it according to its registration interface. +```{warning} +Ensure your plugin's `__init__.py` file imports quickly by keeping all expensive imports in the hook functions locally. +This helps Ape register plugins faster, which is required when checking for API implementations. +``` + ### CLI Plugins The `ape` CLI is built using the python package [click](https://palletsprojects.com/p/click/). diff --git a/src/ape_node/__init__.py b/src/ape_node/__init__.py index f76e94a7c0..26c45369cb 100644 --- a/src/ape_node/__init__.py +++ b/src/ape_node/__init__.py @@ -1,16 +1,17 @@ from ape import plugins -from .provider import EthereumNetworkConfig, EthereumNodeConfig, GethDev, Node -from .query import OtterscanQueryEngine - @plugins.register(plugins.Config) def config_class(): + from ape_node.provider import EthereumNodeConfig + return EthereumNodeConfig @plugins.register(plugins.ProviderPlugin) def providers(): + from ape_node.provider import EthereumNetworkConfig, GethDev, Node + networks_dict = EthereumNetworkConfig().model_dump() networks_dict.pop("local") for network_name in networks_dict: @@ -21,9 +22,22 @@ def providers(): @plugins.register(plugins.QueryPlugin) def query_engines(): + from ape_node.query import OtterscanQueryEngine + yield OtterscanQueryEngine +def __getattr__(name: str): + if name == "OtterscanQueryEngine": + from ape_node.query import OtterscanQueryEngine + + return OtterscanQueryEngine + + import ape_node.provider as module + + return getattr(module, name) + + __all__ = [ "EthereumNetworkConfig", "EthereumNodeConfig", From 9573398923266b5442b79f8d224690ba94dfb0e2 Mon Sep 17 00:00:00 2001 From: antazoey Date: Thu, 21 Nov 2024 01:34:57 +0700 Subject: [PATCH 14/15] fix: auto fork evmchains networks and better access (#2380) --- .pre-commit-config.yaml | 2 +- setup.py | 2 +- src/ape/api/networks.py | 19 +++++++++- src/ape/managers/networks.py | 47 +++++++++++++----------- tests/functional/test_network_api.py | 5 +++ tests/functional/test_network_manager.py | 21 ++++++++++- 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0a339b0ec..db391b7e31 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: ] - repo: https://github.com/executablebooks/mdformat - rev: 0.7.18 + rev: 0.7.19 hooks: - id: mdformat additional_dependencies: [mdformat-gfm, mdformat-frontmatter, mdformat-pyproject] diff --git a/setup.py b/setup.py index 8b2f7d5e57..27b8013b7f 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ "flake8-pydantic", # For detecting issues with Pydantic models "flake8-type-checking", # Detect imports to move in/out of type-checking blocks "isort>=5.13.2,<6", # Import sorting linter - "mdformat>=0.7.18", # Auto-formatter for markdown + "mdformat==0.7.18", # Auto-formatter for markdown "mdformat-gfm>=0.3.5", # Needed for formatting GitHub-flavored markdown "mdformat-frontmatter>=0.4.1", # Needed for frontmatters-style headers in issue templates "mdformat-pyproject>=0.0.1", # Allows configuring in pyproject.toml diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index 07c249135c..601b2633a7 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -318,13 +318,29 @@ def _networks_from_plugins(self) -> dict[str, "NetworkAPI"]: @cached_property def _networks_from_evmchains(self) -> dict[str, "NetworkAPI"]: # NOTE: Purposely exclude plugins here so we also prefer plugins. - return { + networks = { network_name: create_network_type(data["chainId"], data["chainId"])( name=network_name, ecosystem=self ) for network_name, data in PUBLIC_CHAIN_META.get(self.name, {}).items() if network_name not in self._networks_from_plugins } + forked_networks: dict[str, ForkedNetworkAPI] = {} + for network_name, network in networks.items(): + if network_name.endswith("-fork"): + # Already a fork. + continue + + fork_network_name = f"{network_name}-fork" + if any(x == fork_network_name for x in networks): + # The forked version of this network is already known. + continue + + forked_networks[fork_network_name] = ForkedNetworkAPI( + name=fork_network_name, ecosystem=self + ) + + return {**networks, **forked_networks} def __post_init__(self): if len(self.networks) == 0: @@ -535,7 +551,6 @@ def get_network(self, network_name: str) -> "NetworkAPI": Returns: :class:`~ape.api.networks.NetworkAPI` """ - names = {network_name, network_name.replace("-", "_"), network_name.replace("_", "-")} networks = self.networks for name in names: diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index a30c63b06e..a046f90b79 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -241,7 +241,7 @@ def ecosystems(self) -> dict[str, EcosystemAPI]: ) plugin_ecosystems[ecosystem_name] = ecosystem_cls - return plugin_ecosystems + return {**plugin_ecosystems, **self._evmchains_ecosystems} @cached_property def _plugin_ecosystems(self) -> dict[str, EcosystemAPI]: @@ -249,6 +249,30 @@ def _plugin_ecosystems(self) -> dict[str, EcosystemAPI]: plugins = self.plugin_manager.ecosystems return {n: cls(name=n) for n, cls in plugins} # type: ignore[operator] + @cached_property + def _evmchains_ecosystems(self) -> dict[str, EcosystemAPI]: + ecosystems: dict[str, EcosystemAPI] = {} + for name in PUBLIC_CHAIN_META: + ecosystem_name = name.lower().replace(" ", "-") + symbol = None + for net in PUBLIC_CHAIN_META[ecosystem_name].values(): + if not (native_currency := net.get("nativeCurrency")): + continue + + if "symbol" not in native_currency: + continue + + 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__ + ecosystems[name] = evm_class(name=ecosystem_name, fee_token_symbol=symbol) + + return ecosystems + def create_custom_provider( self, connection_str: str, @@ -437,29 +461,9 @@ def get_ecosystem(self, ecosystem_name: str) -> EcosystemAPI: Returns: :class:`~ape.api.networks.EcosystemAPI` """ - if ecosystem_name in self.ecosystem_names: return self.ecosystems[ecosystem_name] - 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 "symbol" not in native_currency: - continue - - 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) - raise EcosystemNotFoundError(ecosystem_name, options=self.ecosystem_names) def get_provider_from_choice( @@ -606,7 +610,6 @@ def set_default_ecosystem(self, ecosystem_name: str): ecosystem_name (str): The name of the ecosystem to set as the default. """ - if ecosystem_name in self.ecosystem_names: self._default_ecosystem_name = ecosystem_name diff --git a/tests/functional/test_network_api.py b/tests/functional/test_network_api.py index 7578a975fa..8ce476f646 100644 --- a/tests/functional/test_network_api.py +++ b/tests/functional/test_network_api.py @@ -349,3 +349,8 @@ def supports_chain(cls, chain_id): ] assert network.explorer is not None assert network.explorer.name == NAME + + +def test_evm_chains_auto_forked_networks_exist(networks): + # NOTE: Moonbeam networks exist in evmchains only; that is how Ape knows about them. + assert isinstance(networks.moonbeam.moonriver_fork, ForkedNetworkAPI) diff --git a/tests/functional/test_network_manager.py b/tests/functional/test_network_manager.py index 73b1acd86f..aabece0481 100644 --- a/tests/functional/test_network_manager.py +++ b/tests/functional/test_network_manager.py @@ -240,7 +240,7 @@ def test_parse_network_choice_multiple_contexts( def test_getattr_ecosystem_with_hyphenated_name(networks, ethereum): - networks.ecosystems["hyphen-in-name"] = networks.ecosystems["ethereum"] + networks._plugin_ecosystems["hyphen-in-name"] = networks.ecosystems["ethereum"] assert networks.hyphen_in_name # Make sure does not raise AttributeError del networks.ecosystems["hyphen-in-name"] @@ -438,7 +438,26 @@ def test_custom_networks_defined_in_non_local_project(custom_networks_config_dic with ape.Project.create_temporary_project(config_override=custom_networks) as temp_project: nm = temp_project.network_manager + + # Tests `.get_ecosystem()` for custom networks. ecosystem = nm.get_ecosystem(eco_name) assert ecosystem.name == eco_name + network = ecosystem.get_network(net_name) assert network.name == net_name + + +def test_get_ecosystem(networks): + ethereum = networks.get_ecosystem("ethereum") + assert isinstance(ethereum, EcosystemAPI) + assert ethereum.name == "ethereum" + + +def test_get_ecosystem_from_evmchains(networks): + """ + Show we can call `.get_ecosystem()` for an ecosystem only + defined in evmchains. + """ + moonbeam = networks.get_ecosystem("moonbeam") + assert isinstance(moonbeam, EcosystemAPI) + assert moonbeam.name == "moonbeam" From 74c77466707c2955db9cdc3359ce7093ae251138 Mon Sep 17 00:00:00 2001 From: antazoey Date: Thu, 21 Nov 2024 07:51:10 +0700 Subject: [PATCH 15/15] docs: handle mdformat updates (#2381) --- docs/userguides/console.md | 6 +++--- setup.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/userguides/console.md b/docs/userguides/console.md index 27b6cf62a2..e7b04e4d79 100644 --- a/docs/userguides/console.md +++ b/docs/userguides/console.md @@ -51,7 +51,7 @@ Follow [this guide](./networks.html) for more information on networks in Ape. ## Namespace Extras -You can also create scripts to be included in the console namespace by adding a file (`ape_console_extras.py`) to your root project directory. All non-internal symbols from this file will be included in the console namespace. Internal symbols are prefixed by an underscore (`_`). +You can also create scripts to be included in the console namespace by adding a file (`ape_console_extras.py`) to your root project directory. All non-internal symbols from this file will be included in the console namespace. Internal symbols are prefixed by an underscore (`_`). An example file might look something like this: @@ -75,7 +75,7 @@ Out[2]: '0x68f768988e9bd4be971d527f72483f321975fa52aff9692b6d0e0af71fb77aaf' ### Init Function -If you include a function named `ape_init_extras`, it will be executed with the symbols from the existing namespace being provided as keyword arguments. This allows you to alter the scripts namespace using locals already included in the Ape namespace. If you return a `dict`, these values will be added to the console namespace. For example, you could set up an initialized Web3.py object by using one from an existing Ape Provider. +If you include a function named `ape_init_extras`, it will be executed with the symbols from the existing namespace being provided as keyword arguments. This allows you to alter the scripts namespace using locals already included in the Ape namespace. If you return a `dict`, these values will be added to the console namespace. For example, you could set up an initialized Web3.py object by using one from an existing Ape Provider. ```python def ape_init_extras(chain): @@ -91,7 +91,7 @@ Out[1]: 1 ### Global Extras -You can also add an `ape_console_extras.py` file to the global ape data directory (`$HOME/.ape/ape_console_extras.py`) and it will execute regardless of what project context you are in. This may be useful for variables and utility functions you use across all of your projects. +You can also add an `ape_console_extras.py` file to the global ape data directory (`$HOME/.ape/ape_console_extras.py`) and it will execute regardless of what project context you are in. This may be useful for variables and utility functions you use across all of your projects. ## Configure diff --git a/setup.py b/setup.py index 27b8013b7f..b9934ee8dc 100644 --- a/setup.py +++ b/setup.py @@ -37,10 +37,10 @@ "flake8-pydantic", # For detecting issues with Pydantic models "flake8-type-checking", # Detect imports to move in/out of type-checking blocks "isort>=5.13.2,<6", # Import sorting linter - "mdformat==0.7.18", # Auto-formatter for markdown + "mdformat>=0.7.19", # Auto-formatter for markdown "mdformat-gfm>=0.3.5", # Needed for formatting GitHub-flavored markdown "mdformat-frontmatter>=0.4.1", # Needed for frontmatters-style headers in issue templates - "mdformat-pyproject>=0.0.1", # Allows configuring in pyproject.toml + "mdformat-pyproject>=0.0.2", # Allows configuring in pyproject.toml ], "doc": ["sphinx-ape"], "release": [ # `release` GitHub Action job uses this