From 0846ac55c73785cbdfa4233804741d6ef3b32bda Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 29 Oct 2024 12:34:53 -0500 Subject: [PATCH] perf: make `ape --help` faster when `safe` installed pt. 2 (#64) --- .pre-commit-config.yaml | 1 + ape_safe/__init__.py | 16 +++++++++------- ape_safe/_cli/click_ext.py | 33 +++++++++++++++++++-------------- ape_safe/_cli/pending.py | 4 +++- ape_safe/accounts.py | 16 +++++++++------- ape_safe/client/base.py | 22 ++++++++++++---------- ape_safe/client/mock.py | 20 +++++++++++--------- ape_safe/config.py | 8 ++++++++ ape_safe/exceptions.py | 14 ++++++++------ ape_safe/multisend.py | 15 +++++++++------ ape_safe/utils.py | 7 +++++-- setup.cfg | 2 ++ setup.py | 4 ++++ 13 files changed, 100 insertions(+), 62 deletions(-) create mode 100644 ape_safe/config.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 873032a..070750a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,7 @@ repos: rev: 7.1.1 hooks: - id: flake8 + additional_dependencies: [flake8-breakpoint, flake8-print, flake8-pydantic, flake8-type-checking] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 diff --git a/ape_safe/__init__.py b/ape_safe/__init__.py index 90fd8b2..6d3d69a 100644 --- a/ape_safe/__init__.py +++ b/ape_safe/__init__.py @@ -1,17 +1,13 @@ from importlib import import_module -from typing import Any, Optional +from typing import Any from ape import plugins -from ape.api import PluginConfig - - -class SafeConfig(PluginConfig): - default_safe: Optional[str] = None - """Alias of the default safe.""" @plugins.register(plugins.Config) def config_class(): + from ape_safe.config import SafeConfig + return SafeConfig @@ -31,6 +27,11 @@ def __getattr__(name: str) -> Any: elif name in ("SafeAccount", "SafeContainer"): return getattr(import_module("ape_safe.accounts"), name) + elif name == "SafeConfig": + from ape_safe.config import SafeConfig + + return SafeConfig + else: raise AttributeError(name) @@ -38,5 +39,6 @@ def __getattr__(name: str) -> Any: __all__ = [ "MultiSend", "SafeAccount", + "SafeConfig", "SafeContainer", ] diff --git a/ape_safe/_cli/click_ext.py b/ape_safe/_cli/click_ext.py index 9ff82e3..21d47b9 100644 --- a/ape_safe/_cli/click_ext.py +++ b/ape_safe/_cli/click_ext.py @@ -2,13 +2,14 @@ from typing import TYPE_CHECKING, NoReturn, Optional, Union, cast import click -from ape.api import AccountAPI from ape.cli import ApeCliContextObject, ape_cli_context from ape.exceptions import Abort -from ape.utils import ManagerAccessMixin from click import BadOptionUsage, MissingParameter if TYPE_CHECKING: + # perf: Keep the CLI module loading fast as possible. + from ape.api import AccountAPI + from ape_safe.accounts import SafeContainer @@ -41,7 +42,7 @@ def _txn_ids_callback(ctx, param, value): ) -class CallbackFactory(ManagerAccessMixin): +class CallbackFactory: """ Helper class to prevent circular import and have access to Ape objects. @@ -49,14 +50,16 @@ class CallbackFactory(ManagerAccessMixin): @classmethod def safe_callback(cls, ctx, param, value): + from ape.utils import ManagerAccessMixin as access + # NOTE: For some reason, the Cli CTX object is not the SafeCliCtx yet at this point. - safes = cls.account_manager.containers["safe"] + safes = access.account_manager.containers["safe"] if value is None: # First, check config for a default. If one is there, # we must use that. - safe_config = cls.config_manager.get_config("safe") + safe_config = access.config_manager.get_config("safe") if alias := safe_config.default_safe: - return cls.account_manager.load(alias) + return access.account_manager.load(alias) # If there is only 1 safe, just use that. elif len(safes) == 1: @@ -69,7 +72,7 @@ def safe_callback(cls, ctx, param, value): raise MissingParameter(message=f"Must specify one of '{options}').") elif value in safes.aliases: - return cls.account_manager.load(value) + return access.account_manager.load(value) else: raise BadOptionUsage("--safe", f"No safe with alias '{value}'") @@ -79,16 +82,18 @@ def submitter_callback(cls, ctx, param, val): if val is None: return None - elif val in cls.account_manager.aliases: - return cls.account_manager.load(val) + from ape.utils import ManagerAccessMixin as access + + if val in access.account_manager.aliases: + return access.account_manager.load(val) # Account address - execute using this account. - elif val in cls.account_manager: - return cls.account_manager[val] + elif val in access.account_manager: + return access.account_manager[val] # Saying "yes, execute". Use first "local signer". elif val.lower() in ("true", "t", "1"): - safe = cls.account_manager.load(ctx.params["alias"]) + safe = access.account_manager.load(ctx.params["alias"]) if not safe.local_signers: ctx.obj.abort("Cannot use `--execute TRUE` without a local signer.") @@ -97,7 +102,7 @@ def submitter_callback(cls, ctx, param, val): return None @classmethod - def sender_callback(cls, ctx, param, val) -> Optional[Union[AccountAPI, bool]]: + def sender_callback(cls, ctx, param, val) -> Optional[Union["AccountAPI", bool]]: """ Either returns the account or ``False`` meaning don't execute. NOTE: The handling of the `--execute` flag in the `pending` CLI @@ -107,7 +112,7 @@ def sender_callback(cls, ctx, param, val) -> Optional[Union[AccountAPI, bool]]: return cls._get_execute_callback(ctx, param, val, name="sender") @classmethod - def execute_callback(cls, ctx, param, val) -> Optional[Union[AccountAPI, bool]]: + def execute_callback(cls, ctx, param, val) -> Optional[Union["AccountAPI", bool]]: """ Either returns the account or ``False`` meaning don't execute. """ diff --git a/ape_safe/_cli/pending.py b/ape_safe/_cli/pending.py index b5c8056..ecd1400 100644 --- a/ape_safe/_cli/pending.py +++ b/ape_safe/_cli/pending.py @@ -5,7 +5,6 @@ import rich from ape.cli import ConnectedProviderCommand from ape.exceptions import SignatureError -from ape.types import AddressType, MessageSignature from eth_typing import ChecksumAddress, Hash32 from eth_utils import humanize_hash from hexbytes import HexBytes @@ -269,6 +268,9 @@ def execute(cli_ctx, safe, txn_ids, submitter, nonce): def _execute(safe: "SafeAccount", txn: "UnexecutedTxData", submitter: "AccountAPI", **tx_kwargs): + # perf: Avoid these imports during CLI load time for `ape --help` performance. + from ape.types import AddressType, MessageSignature + safe_tx = safe.create_safe_tx(**txn.model_dump(mode="json", by_alias=True)) signatures: dict[AddressType, MessageSignature] = { c.owner: MessageSignature.from_rsv(c.signature) for c in txn.confirmations diff --git a/ape_safe/accounts.py b/ape_safe/accounts.py index 04c4bf7..efad1aa 100644 --- a/ape_safe/accounts.py +++ b/ape_safe/accounts.py @@ -2,13 +2,11 @@ import os from collections.abc import Iterable, Iterator, Mapping from pathlib import Path -from typing import Any, Dict, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast from ape.api import AccountAPI, AccountContainerAPI, ReceiptAPI, TransactionAPI -from ape.api.address import BaseAddress from ape.api.networks import ForkedNetworkAPI from ape.cli import select_account -from ape.contracts import ContractInstance from ape.exceptions import ContractNotFoundError, ProviderNotConnectedError from ape.logging import logger from ape.managers.accounts import AccountManager, TestAccountManager @@ -37,6 +35,10 @@ ) from ape_safe.utils import get_safe_tx_hash, order_by_signer +if TYPE_CHECKING: + from ape.api.address import BaseAddress + from ape.contracts import ContractInstance + class SafeContainer(AccountContainerAPI): _accounts: Dict[str, "SafeAccount"] = {} @@ -215,7 +217,7 @@ def address(self) -> AddressType: return ecosystem.decode_address(self.account_file["address"]) @cached_property - def contract(self) -> ContractInstance: + def contract(self) -> "ContractInstance": safe_contract = self.chain_manager.contracts.instance_at(self.address) if self.fallback_handler: contract_signatures = {x.signature for x in safe_contract.contract_type.abi} @@ -236,7 +238,7 @@ def contract(self) -> ContractInstance: return safe_contract @cached_property - def fallback_handler(self) -> Optional[ContractInstance]: + def fallback_handler(self) -> Optional["ContractInstance"]: slot = keccak(text="fallback_manager.handler.address") value = self.provider.get_storage(self.address, slot) address = self.network_manager.ecosystem.decode_address(value[-20:]) @@ -408,7 +410,7 @@ def create_execute_transaction( *exec_args, encoded_signatures, **txn_options ) - def compute_prev_signer(self, signer: Union[str, AddressType, BaseAddress]) -> AddressType: + def compute_prev_signer(self, signer: Union[str, AddressType, "BaseAddress"]) -> AddressType: """ Sometimes it's handy to have "previous owner" for ownership change operations, this function makes it easy to calculate. @@ -465,7 +467,7 @@ def estimate_gas_cost(self, **kwargs) -> int: ) def _preapproved_signature( - self, signer: Union[AddressType, BaseAddress, str] + self, signer: Union[AddressType, "BaseAddress", str] ) -> MessageSignature: # Get the Safe-style "preapproval" signature type, which is a sentinel value used to denote # when a signer approved via some other method, such as `approveHash` or being `msg.sender` diff --git a/ape_safe/client/base.py b/ape_safe/client/base.py index ad50fa2..4b91687 100644 --- a/ape_safe/client/base.py +++ b/ape_safe/client/base.py @@ -1,13 +1,11 @@ from abc import ABC, abstractmethod from collections.abc import Iterator from functools import cached_property -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, Union import certifi import requests import urllib3 -from ape.types import AddressType, MessageSignature -from requests import Response from requests.adapters import HTTPAdapter from ape_safe.client.types import ( @@ -21,6 +19,10 @@ ) from ape_safe.exceptions import ClientResponseError +if TYPE_CHECKING: + from ape.types import AddressType, MessageSignature + from requests import Response + DEFAULT_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", @@ -48,19 +50,19 @@ def get_confirmations(self, safe_tx_hash: SafeTxID) -> Iterator[SafeTxConfirmati @abstractmethod def post_transaction( - self, safe_tx: SafeTx, signatures: dict[AddressType, MessageSignature], **kwargs + self, safe_tx: SafeTx, signatures: dict["AddressType", "MessageSignature"], **kwargs ): ... @abstractmethod def post_signatures( self, safe_tx_or_hash: Union[SafeTx, SafeTxID], - signatures: dict[AddressType, MessageSignature], + signatures: dict["AddressType", "MessageSignature"], ): ... @abstractmethod def estimate_gas_cost( - self, receiver: AddressType, value: int, data: bytes, operation: int = 0 + self, receiver: "AddressType", value: int, data: bytes, operation: int = 0 ) -> Optional[int]: ... """Shared methods""" @@ -71,7 +73,7 @@ def get_transactions( starting_nonce: int = 0, ending_nonce: Optional[int] = None, filter_by_ids: Optional[set[SafeTxID]] = None, - filter_by_missing_signers: Optional[set[AddressType]] = None, + filter_by_missing_signers: Optional[set["AddressType"]] = None, ) -> Iterator[SafeApiTxData]: """ confirmed: Confirmed if True, not confirmed if False, both if None @@ -126,17 +128,17 @@ def session(self) -> requests.Session: session.mount("https://", adapter) return session - def _get(self, url: str) -> Response: + def _get(self, url: str) -> "Response": return self._request("GET", url) - def _post(self, url: str, json: Optional[dict] = None, **kwargs) -> Response: + def _post(self, url: str, json: Optional[dict] = None, **kwargs) -> "Response": return self._request("POST", url, json=json, **kwargs) @cached_property def _http(self): return urllib3.PoolManager(ca_certs=certifi.where()) - def _request(self, method: str, url: str, json: Optional[dict] = None, **kwargs) -> Response: + def _request(self, method: str, url: str, json: Optional[dict] = None, **kwargs) -> "Response": # NOTE: paged requests include full url already if url.startswith(f"{self.transaction_service_url}/api/v1/"): api_url = url diff --git a/ape_safe/client/mock.py b/ape_safe/client/mock.py index b424d60..80225c6 100644 --- a/ape_safe/client/mock.py +++ b/ape_safe/client/mock.py @@ -1,9 +1,7 @@ from collections.abc import Iterator from datetime import datetime, timezone -from typing import Optional, Union, cast +from typing import TYPE_CHECKING, Optional, Union, cast -from ape.contracts import ContractInstance -from ape.types import AddressType, MessageSignature from ape.utils import ZERO_ADDRESS, ManagerAccessMixin from eth_utils import keccak from hexbytes import HexBytes @@ -20,9 +18,13 @@ ) from ape_safe.utils import get_safe_tx_hash +if TYPE_CHECKING: + from ape.contracts import ContractInstance + from ape.types import AddressType, MessageSignature + class MockSafeClient(BaseSafeClient, ManagerAccessMixin): - def __init__(self, contract: ContractInstance): + def __init__(self, contract: "ContractInstance"): self.contract = contract self.transactions: dict[SafeTxID, SafeApiTxData] = {} self.transactions_by_nonce: dict[int, list[SafeTxID]] = {} @@ -47,13 +49,13 @@ def safe_details(self) -> SafeDetails: ) @property - def guard(self) -> AddressType: + def guard(self) -> "AddressType": return ( self.contract.getGuard() if "getGuard" in self.contract._view_methods_ else ZERO_ADDRESS ) @property - def modules(self) -> list[AddressType]: + def modules(self) -> list["AddressType"]: return self.contract.getModules() if "getModules" in self.contract._view_methods_ else [] def get_next_nonce(self) -> int: @@ -73,7 +75,7 @@ def get_confirmations(self, safe_tx_hash: SafeTxID) -> Iterator[SafeTxConfirmati yield from safe_tx_data.confirmations def post_transaction( - self, safe_tx: SafeTx, signatures: dict[AddressType, MessageSignature], **kwargs + self, safe_tx: SafeTx, signatures: dict["AddressType", "MessageSignature"], **kwargs ): safe_tx_data = UnexecutedTxData.from_safe_tx(safe_tx, self.safe_details.threshold) safe_tx_data.confirmations.extend( @@ -95,7 +97,7 @@ def post_transaction( def post_signatures( self, safe_tx_or_hash: Union[SafeTx, SafeTxID], - signatures: dict[AddressType, MessageSignature], + signatures: dict["AddressType", "MessageSignature"], ): for signer, signature in signatures.items(): safe_tx_id = ( @@ -114,6 +116,6 @@ def post_signatures( ) def estimate_gas_cost( - self, receiver: AddressType, value: int, data: bytes, operation: int = 0 + self, receiver: "AddressType", value: int, data: bytes, operation: int = 0 ) -> Optional[int]: return None # Estimate gas normally diff --git a/ape_safe/config.py b/ape_safe/config.py new file mode 100644 index 0000000..3946b08 --- /dev/null +++ b/ape_safe/config.py @@ -0,0 +1,8 @@ +from typing import Optional + +from ape.api import PluginConfig + + +class SafeConfig(PluginConfig): + default_safe: Optional[str] = None + """Alias of the default safe.""" diff --git a/ape_safe/exceptions.py b/ape_safe/exceptions.py index 2e8d845..c74d467 100644 --- a/ape_safe/exceptions.py +++ b/ape_safe/exceptions.py @@ -1,9 +1,11 @@ from contextlib import ContextDecorator -from typing import Optional +from typing import TYPE_CHECKING, Optional from ape.exceptions import AccountsError, ApeException, ContractLogicError, SignatureError -from ape.types import AddressType -from requests import Response + +if TYPE_CHECKING: + from ape.types import AddressType + from requests import Response class ApeSafeException(ApeException): @@ -17,7 +19,7 @@ class ApeSafeError(ApeSafeException, AccountsError): class NotASigner(ApeSafeException): - def __init__(self, signer: AddressType): + def __init__(self, signer: "AddressType"): super().__init__(f"{signer} is not a valid signer.") @@ -122,7 +124,7 @@ def __init__(self, message: str): class ClientResponseError(SafeClientException): - def __init__(self, endpoint_url: str, response: Response, message: Optional[str] = None): + def __init__(self, endpoint_url: str, response: "Response", message: Optional[str] = None): self.endpoint_url = endpoint_url self.response = response message = message or f"Exception when calling '{endpoint_url}':\n{response.text}" @@ -130,6 +132,6 @@ def __init__(self, endpoint_url: str, response: Response, message: Optional[str] class MultisigTransactionNotFoundError(ClientResponseError): - def __init__(self, tx_hash: str, endpoint_url: str, response: Response): + def __init__(self, tx_hash: str, endpoint_url: str, response: "Response"): message = f"Multisig transaction '{tx_hash}' not found." super().__init__(endpoint_url, response, message=message) diff --git a/ape_safe/multisend.py b/ape_safe/multisend.py index b0bc64d..9847f9a 100644 --- a/ape_safe/multisend.py +++ b/ape_safe/multisend.py @@ -1,9 +1,8 @@ from importlib.resources import files from io import BytesIO +from typing import TYPE_CHECKING from ape import convert -from ape.api import ReceiptAPI, TransactionAPI -from ape.contracts.base import ContractInstance, ContractTransactionHandler from ape.types import AddressType, HexBytes from ape.utils import ManagerAccessMixin, cached_property from eth_abi.packed import encode_packed @@ -11,6 +10,10 @@ from ape_safe.exceptions import UnsupportedChainError, ValueRequired +if TYPE_CHECKING: + from ape.api import ReceiptAPI, TransactionAPI + from ape.contracts.base import ContractInstance, ContractTransactionHandler + MULTISEND_CALL_ONLY_ADDRESSES = ( "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", # MultiSend Call Only v1.3.0 "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", # MultiSend Call Only v1.3.0 (EIP-155) @@ -80,7 +83,7 @@ def multisend(): ) @cached_property - def contract(self) -> ContractInstance: + def contract(self) -> "ContractInstance": for address in MULTISEND_CALL_ONLY_ADDRESSES: if self.provider.get_code(address) == MULTISEND_CALL_ONLY.get_runtime_bytecode(): return self.chain_manager.contracts.instance_at( @@ -90,7 +93,7 @@ def contract(self) -> ContractInstance: raise UnsupportedChainError() @property - def handler(self) -> ContractTransactionHandler: + def handler(self) -> "ContractTransactionHandler": return self.contract.multiSend def add( @@ -149,7 +152,7 @@ def encoded_calls(self): for call in self.calls ] - def __call__(self, **txn_kwargs) -> ReceiptAPI: + def __call__(self, **txn_kwargs) -> "ReceiptAPI": """ Execute the MultiSend transaction. The transaction will broadcast again every time the ``Transaction`` object is called. @@ -169,7 +172,7 @@ def __call__(self, **txn_kwargs) -> ReceiptAPI: txn_kwargs["operation"] = 1 return self.handler(b"".join(self.encoded_calls), **txn_kwargs) - def as_transaction(self, **txn_kwargs) -> TransactionAPI: + def as_transaction(self, **txn_kwargs) -> "TransactionAPI": """ Encode the MultiSend transaction as a ``TransactionAPI`` object, but do not execute it. diff --git a/ape_safe/utils.py b/ape_safe/utils.py index e8eca51..d3962f4 100644 --- a/ape_safe/utils.py +++ b/ape_safe/utils.py @@ -1,15 +1,18 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, cast -from ape.types import AddressType, MessageSignature from eip712.messages import calculate_hash from eth_utils import to_int if TYPE_CHECKING: + from ape.types import AddressType, MessageSignature + from ape_safe.client.types import SafeTxID -def order_by_signer(signatures: Mapping[AddressType, MessageSignature]) -> list[MessageSignature]: +def order_by_signer( + signatures: Mapping["AddressType", "MessageSignature"] +) -> list["MessageSignature"]: # NOTE: Must order signatures in ascending order of signer address (converted to int) return list(signatures[signer] for signer in sorted(signatures, key=lambda a: to_int(hexstr=a))) diff --git a/setup.cfg b/setup.cfg index c362920..a71f67c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,9 @@ [flake8] max-line-length = 100 +ignore = E704,W503,PYD002,TC003,TC006 exclude = venv* .eggs docs build +type-checking-pydantic-enabled = True diff --git a/setup.py b/setup.py index fbf7aac..459c705 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,10 @@ "types-requests", # Needed for mypy type shed "types-setuptools", # Needed for mypy type shed "flake8>=7.1.1,<8", # Style linter + "flake8-breakpoint>=1.1.0,<2", # Detect breakpoints left in code + "flake8-print>=4.0.1,<5", # Detect print statements left in code + "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,<0.8", # Docs formatter and linter "mdformat-pyproject>=0.0.1", # Allows configuring in pyproject.toml