From 1e399a9b1a02d0dd10f87e631c6f384f8218e4f6 Mon Sep 17 00:00:00 2001 From: antazoey Date: Mon, 1 Apr 2024 09:58:54 -0600 Subject: [PATCH 01/24] fix: support latest web3 changes (#1978) --- setup.py | 4 +- src/ape_ethereum/provider.py | 66 ++++++++++++---------- tests/functional/test_contract_instance.py | 3 +- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/setup.py b/setup.py index 5d1001f387..7daecfbe8e 100644 --- a/setup.py +++ b/setup.py @@ -121,8 +121,8 @@ "eth-account>=0.10.0,<0.11", "eth-typing>=3.5.2,<4", "eth-utils>=2.3.1,<3", - "py-geth>=4.2.0,<5", - "web3[tester]>=6.15.1,<7", + "py-geth>=4.4.0,<5", + "web3[tester]>=6.16.0,<7", # ** Dependencies maintained by ApeWorX ** "eip712>=0.2.3,<0.4", "ethpm-types>=0.6.7,<0.7", diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index 69d95eb1dc..6188832ef0 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -14,7 +14,7 @@ import requests from eth_pydantic_types import HexBytes from eth_typing import BlockNumber, HexStr -from eth_utils import add_0x_prefix, to_hex +from eth_utils import add_0x_prefix, is_hex, to_hex from ethpm_types import EventABI from evm_trace import CallTreeNode as EvmCallTreeNode from evm_trace import ParityTraceList @@ -407,7 +407,6 @@ def send_call( ) -> HexBytes: if block_id is not None: kwargs["block_identifier"] = block_id - if kwargs.pop("skip_trace", False): return self._send_call(txn, **kwargs) elif self._test_runner is not None: @@ -1146,7 +1145,14 @@ def _handle_execution_reverted( contract_address: Optional[AddressType] = None, source_traceback: Optional[SourceTraceback] = None, ) -> ContractLogicError: - message = str(exception).split(":")[-1].strip() + + if hasattr(exception, "args") and len(exception.args) == 2: + message = exception.args[0] + data = exception.args[1] + else: + message = str(exception).split(":")[-1].strip() + data = None + params: Dict = { "trace": trace, "contract_address": contract_address, @@ -1155,31 +1161,35 @@ def _handle_execution_reverted( no_reason = message == "execution reverted" if isinstance(exception, Web3ContractLogicError) and no_reason: - # Check for custom exception data and use that as the message instead. - # This allows compiler exception enrichment to function. - err_trace = None - try: - if trace: - trace, err_trace = tee(trace) - elif txn: - err_trace = self.provider.get_transaction_trace(txn.txn_hash.hex()) - + if data is None: + # Check for custom exception data and use that as the message instead. + # This allows compiler exception enrichment to function. + err_trace = None try: - trace_ls: List[TraceFrame] = list(err_trace) if err_trace else [] - except Exception as err: - logger.error(f"Failed getting traceback: {err}") - trace_ls = [] - - data = trace_ls[-1].raw if len(trace_ls) > 0 else {} - memory = data.get("memory", []) - return_value = "".join([x[2:] for x in memory[4:]]) - if return_value: - message = f"0x{return_value}" - no_reason = False - - except (ApeException, NotImplementedError): - # Either provider does not support or isn't a custom exception. - pass + if trace: + trace, err_trace = tee(trace) + elif txn: + err_trace = self.provider.get_transaction_trace(txn.txn_hash.hex()) + + try: + trace_ls: List[TraceFrame] = list(err_trace) if err_trace else [] + except Exception as err: + logger.error(f"Failed getting traceback: {err}") + trace_ls = [] + + data = trace_ls[-1].raw if len(trace_ls) > 0 else {} + memory = data.get("memory", []) + return_value = "".join([x[2:] for x in memory[4:]]) + if return_value: + message = f"0x{return_value}" + no_reason = False + + except (ApeException, NotImplementedError): + # Either provider does not support or isn't a custom exception. + pass + + elif data != "no data" and is_hex(data): + message = add_0x_prefix(data) result = ( ContractLogicError(txn=txn, **params) @@ -1313,8 +1323,6 @@ def _complete_connect(self): else "Error getting chain id." ) - is_likely_poa = False - # NOTE: We have to check both earliest and latest # because if the chain was _ever_ PoA, we need # this middleware. diff --git a/tests/functional/test_contract_instance.py b/tests/functional/test_contract_instance.py index d3ada51e5d..1bfd9a61b0 100644 --- a/tests/functional/test_contract_instance.py +++ b/tests/functional/test_contract_instance.py @@ -772,7 +772,8 @@ def test_identifier_lookup(vyper_contract_instance): def test_source_path(project_with_contract, owner): contracts_folder = project_with_contract.contracts_folder - contract = project_with_contract.contracts["Contract"] + contracts = project_with_contract.load_contracts() + contract = contracts["Contract"] contract_instance = owner.deploy(project_with_contract.get_contract("Contract")) expected = contracts_folder / contract.source_id From b969106288f2dbd660093c8fb54b468262b7cca0 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 2 Apr 2024 14:13:26 -0600 Subject: [PATCH 02/24] fix: issue getting correct error message for contract-reverts (#1979) --- src/ape_ethereum/provider.py | 2 +- tests/functional/geth/test_provider.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index 6188832ef0..b6a78d64f1 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -1147,7 +1147,7 @@ def _handle_execution_reverted( ) -> ContractLogicError: if hasattr(exception, "args") and len(exception.args) == 2: - message = exception.args[0] + message = exception.args[0].replace("execution reverted: ", "") data = exception.args[1] else: message = str(exception).split(":")[-1].strip() diff --git a/tests/functional/geth/test_provider.py b/tests/functional/geth/test_provider.py index a7046408fb..42a0895818 100644 --- a/tests/functional/geth/test_provider.py +++ b/tests/functional/geth/test_provider.py @@ -6,6 +6,7 @@ from eth_typing import HexStr from evmchains import PUBLIC_CHAIN_META from hexbytes import HexBytes +from web3.exceptions import ContractLogicError as Web3ContractLogicError from web3.exceptions import ExtraDataLengthError from web3.middleware import geth_poa_middleware @@ -489,3 +490,11 @@ def hacked_send_call(*args, **kwargs): actual = provider._send_call.call_args[-1]["block_identifier"] assert actual == block_id + + +@geth_process_test +def test_get_virtual_machine_error(geth_provider): + expected = "__EXPECTED__" + error = Web3ContractLogicError(f"execution reverted: {expected}", "0x08c379a") + actual = geth_provider.get_virtual_machine_error(error) + assert actual.message == expected From 45e0079dfb880a3db8829c22114634a2efd928c6 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:04:18 +0400 Subject: [PATCH 03/24] fix: multicall allow failure (#1975) * fix: multicall allow failure * fix: debug print * fix: revert allow failure to camel case * test: update multicall test * lint: isort --- src/ape_ethereum/multicall/handlers.py | 35 +++++--------------------- tests/functional/test_multicall.py | 23 +++++++++-------- 2 files changed, 19 insertions(+), 39 deletions(-) diff --git a/src/ape_ethereum/multicall/handlers.py b/src/ape_ethereum/multicall/handlers.py index 1a2f00f7fb..33d34dad03 100644 --- a/src/ape_ethereum/multicall/handlers.py +++ b/src/ape_ethereum/multicall/handlers.py @@ -21,7 +21,7 @@ MULTICALL3_CONTRACT_TYPE, SUPPORTED_CHAINS, ) -from .exceptions import InvalidOption, NotExecutedError, UnsupportedChainError, ValueRequired +from .exceptions import InvalidOption, UnsupportedChainError, ValueRequired class BaseMulticall(ManagerAccessMixin): @@ -87,17 +87,13 @@ def handler(self) -> ContractTransactionHandler: if any(call["value"] > 0 for call in self.calls): return self.contract.aggregate3Value - elif any(call["allowFailure"] for call in self.calls): - return self.contract.aggregate3 - - else: - return self.contract.aggregate + return self.contract.aggregate3 def add( self, call: ContractMethodHandler, *args, - allowFailure: bool = False, + allowFailure: bool = True, value: int = 0, ) -> "BaseMulticall": """ @@ -164,8 +160,7 @@ def __init__( super().__init__(address=address, supported_chains=supported_chains) self.abis: List[MethodABI] = [] - self._result: Union[None, Tuple[int, List[HexBytes]], List[Tuple[bool, HexBytes]]] = None - self._failed_results: List[HexBytes] = [] + self._result: Union[None, List[Tuple[bool, HexBytes]]] = None @property def handler(self) -> ContractCallHandler: # type: ignore[override] @@ -183,29 +178,11 @@ def add(self, call: ContractMethodHandler, *args, **kwargs): def returnData(self) -> List[HexBytes]: # NOTE: this property is kept camelCase to align with the raw EVM struct result = self._result # Declare for typing reasons. - if not result: - raise NotExecutedError() - - elif ( - isinstance(result, (tuple, list)) - and len(result) >= 2 - and type(result[0]) is bool - and isinstance(result[1], bytes) - ): - # Call3[] or Call3Value[] when only single call. - return [result[1]] - - elif isinstance(result, tuple): - # Call3[] or Call3Value[] when multiple calls. - return list(r[1] for r in self._result) # type: ignore - - else: - # blockNumber: uint256, returnData: Call[] - return result.returnData # type: ignore + return [res.returnData if res.success else None for res in result] # type: ignore def _decode_results(self) -> Iterator[Any]: for abi, data in zip(self.abis, self.returnData): - if data in self._failed_results: + if data is None: # The call failed. yield data continue diff --git a/tests/functional/test_multicall.py b/tests/functional/test_multicall.py index cb80950461..a3a80415f5 100644 --- a/tests/functional/test_multicall.py +++ b/tests/functional/test_multicall.py @@ -1,4 +1,4 @@ -from typing import List +from typing import NamedTuple import pytest from eth_pydantic_types import HexBytes @@ -12,14 +12,14 @@ RETURNDATA = HexBytes("0x4a821464") -class ReturndataClass: - returnData: List[HexBytes] = [RETURNDATA] +class ReturnData(NamedTuple): + success: bool + returnData: bytes RETURNDATA_PARAMS = { - "result": ReturndataClass(), - # Happens when using Call() for a single call. - "result_single": [False, RETURNDATA], + "result_ok": (ReturnData(True, RETURNDATA), RETURNDATA), + "result_fail": (ReturnData(False, RETURNDATA), None), } @@ -53,7 +53,10 @@ def test_unsupported_chain(call_handler_with_struct_input, struct_input_for_call def test_aggregate3_input( - aggregate3, call_handler_with_struct_input, struct_input_for_call, vyper_contract_instance + aggregate3, + call_handler_with_struct_input, + struct_input_for_call, + vyper_contract_instance, ): call = Call() @@ -67,7 +70,7 @@ def test_aggregate3_input( @pytest.mark.parametrize("returndata_key", RETURNDATA_PARAMS) def test_returndata(returndata_key): - returndata = RETURNDATA_PARAMS[returndata_key] + result, output = RETURNDATA_PARAMS[returndata_key] call = Call() - call._result = returndata # type: ignore - assert call.returnData[0] == HexBytes("0x4a821464") + call._result = [result] # type: ignore + assert call.returnData[0] == output From 2c9262314372a8d8ddced8afa0d1dd8aa17ddb56 Mon Sep 17 00:00:00 2001 From: antazoey Date: Wed, 3 Apr 2024 09:02:12 -0600 Subject: [PATCH 04/24] feat: util method to help prevent failures from happening in repr methods (#1985) --- .pre-commit-config.yaml | 4 ++-- setup.py | 4 ++-- src/ape/api/address.py | 3 ++- src/ape/api/compiler.py | 8 +++++++- src/ape/api/networks.py | 2 ++ src/ape/api/projects.py | 7 +++++-- src/ape/api/providers.py | 12 +++++------- src/ape/api/transactions.py | 3 +++ src/ape/contracts/base.py | 18 +++++++++++++++-- src/ape/managers/accounts.py | 4 +++- src/ape/managers/chain.py | 9 ++++++++- src/ape/managers/compilers.py | 4 +++- src/ape/managers/config.py | 5 +++-- src/ape/managers/converters.py | 5 +++-- src/ape/managers/networks.py | 5 +++-- src/ape/managers/project/dependency.py | 4 +++- src/ape/managers/project/manager.py | 12 ++++-------- src/ape/plugins/__init__.py | 4 +++- src/ape/plugins/_utils.py | 20 ++++++------------- src/ape/types/__init__.py | 4 +++- src/ape/types/signatures.py | 3 +++ src/ape/types/trace.py | 5 ++++- src/ape/utils/__init__.py | 2 ++ src/ape/utils/misc.py | 27 ++++++++++++++++++++++++++ src/ape_accounts/accounts.py | 16 +++++---------- src/ape_geth/provider.py | 9 ++++----- tests/functional/test_transaction.py | 11 +++++++++++ tests/functional/utils/test_misc.py | 10 ++++++++++ tests/integration/cli/conftest.py | 8 ++++++-- tests/integration/cli/test_cache.py | 4 ++++ 30 files changed, 162 insertions(+), 70 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 52aae20dd9..ecaa81366e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: - id: isort - repo: https://github.com/psf/black - rev: 24.2.0 + rev: 24.3.0 hooks: - id: black name: black @@ -21,7 +21,7 @@ repos: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.9.0 hooks: - id: mypy additional_dependencies: [ diff --git a/setup.py b/setup.py index 7daecfbe8e..64e69944d3 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,8 @@ "hypothesis-jsonschema==0.19.0", # JSON Schema fuzzer extension ], "lint": [ - "black>=24.2.0,<25", # Auto-formatter and linter - "mypy>=1.8.0,<2", # Static type analyzer + "black>=24.3.0,<25", # Auto-formatter and linter + "mypy>=1.9.0,<2", # Static type analyzer "types-PyYAML", # Needed due to mypy typeshed "types-requests", # Needed due to mypy typeshed "types-setuptools", # Needed due to mypy typeshed diff --git a/src/ape/api/address.py b/src/ape/api/address.py index 486dd6eb6c..7aa16222af 100644 --- a/src/ape/api/address.py +++ b/src/ape/api/address.py @@ -4,7 +4,7 @@ from ape.exceptions import ConversionError from ape.types import AddressType, ContractCode -from ape.utils import BaseInterface, abstractmethod, cached_property +from ape.utils import BaseInterface, abstractmethod, cached_property, log_instead_of_fail if TYPE_CHECKING: from ape.api.transactions import ReceiptAPI, TransactionAPI @@ -71,6 +71,7 @@ def __dir__(self) -> List[str]: """ return self._base_dir_values + @log_instead_of_fail(default="") def __repr__(self) -> str: cls_name = getattr(type(self), "__name__", BaseAddress.__name__) return f"<{cls_name} {self.address}>" diff --git a/src/ape/api/compiler.py b/src/ape/api/compiler.py index 78eb2ddcb6..09c15badd5 100644 --- a/src/ape/api/compiler.py +++ b/src/ape/api/compiler.py @@ -13,7 +13,12 @@ from ape.exceptions import APINotImplementedError, ContractLogicError from ape.types.coverage import ContractSourceCoverage from ape.types.trace import SourceTraceback, TraceFrame -from ape.utils import BaseInterfaceModel, abstractmethod, raises_not_implemented +from ape.utils import ( + BaseInterfaceModel, + abstractmethod, + log_instead_of_fail, + raises_not_implemented, +) class CompilerAPI(BaseInterfaceModel): @@ -159,6 +164,7 @@ def get_version_map( # type: ignore[empty-body] Dict[Version, Set[Path]] """ + @log_instead_of_fail(default="") def __repr__(self) -> str: cls_name = getattr(type(self), "__name__", CompilerAPI.__name__) return f"<{cls_name} {self.name}>" diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index 8554c3d1d9..03a9b1c6a3 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -43,6 +43,7 @@ ManagerAccessMixin, abstractmethod, cached_property, + log_instead_of_fail, raises_not_implemented, ) @@ -91,6 +92,7 @@ class EcosystemAPI(ExtraAttributesMixin, BaseInterfaceModel): _default_network: Optional[str] = None """The default network of the ecosystem, such as ``local``.""" + @log_instead_of_fail(default="") def __repr__(self) -> str: return f"<{self.name}>" diff --git a/src/ape/api/projects.py b/src/ape/api/projects.py index 2fe6b21aaf..c3f57c3f2b 100644 --- a/src/ape/api/projects.py +++ b/src/ape/api/projects.py @@ -19,6 +19,7 @@ cached_property, get_all_files_in_directory, get_relative_path, + log_instead_of_fail, ) if TYPE_CHECKING: @@ -49,7 +50,8 @@ class ProjectAPI(BaseInterfaceModel): _contracts: Optional[Dict[str, ContractType]] = None - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: cls_name = getattr(type(self), "__name__", ProjectAPI.__name__) return f"<{cls_name} {self.path.name}>" @@ -359,7 +361,8 @@ class DependencyAPI(ExtraAttributesMixin, BaseInterfaceModel): _cached_manifest: Optional[PackageManifest] = None - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: cls_name = getattr(type(self), "__name__", DependencyAPI.__name__) return f"<{cls_name} name='{self.name}'>" diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index 39aa8632d7..944a9bf602 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -40,16 +40,13 @@ SnapshotID, TraceFrame, ) -from ape.utils import ( +from ape.utils import BaseInterfaceModel, JoinableQueue, abstractmethod, cached_property, spawn +from ape.utils.misc import ( EMPTY_BYTES32, - BaseInterfaceModel, - JoinableQueue, - abstractmethod, - cached_property, + _create_raises_not_implemented_error, + log_instead_of_fail, raises_not_implemented, - spawn, ) -from ape.utils.misc import _create_raises_not_implemented_error class BlockAPI(BaseInterfaceModel): @@ -580,6 +577,7 @@ def set_balance(self, address: AddressType, amount: int): amount (int): The balance to set in the address. """ + @log_instead_of_fail(default="") def __repr__(self) -> str: try: chain_id = self.chain_id diff --git a/src/ape/api/transactions.py b/src/ape/api/transactions.py index 78dbb44426..4b63a6f981 100644 --- a/src/ape/api/transactions.py +++ b/src/ape/api/transactions.py @@ -33,6 +33,7 @@ ExtraModelAttributes, abstractmethod, cached_property, + log_instead_of_fail, raises_not_implemented, ) @@ -173,6 +174,7 @@ def serialize_transaction(self) -> bytes: Serialize the transaction """ + @log_instead_of_fail(default="") def __repr__(self) -> str: data = self.model_dump(mode="json") params = ", ".join(f"{k}={v}" for k, v in data.items()) @@ -270,6 +272,7 @@ class ReceiptAPI(ExtraAttributesMixin, BaseInterfaceModel): txn_hash: str transaction: TransactionAPI + @log_instead_of_fail(default="") def __repr__(self) -> str: cls_name = getattr(self.__class__, "__name__", ReceiptAPI.__name__) return f"<{cls_name} {self.txn_hash}>" diff --git a/src/ape/contracts/base.py b/src/ape/contracts/base.py index face4bec4c..d8d2a32e00 100644 --- a/src/ape/contracts/base.py +++ b/src/ape/contracts/base.py @@ -28,7 +28,13 @@ ) from ape.logging import logger from ape.types import AddressType, ContractLog, LogFilter, MockContractLog -from ape.utils import BaseInterfaceModel, ManagerAccessMixin, cached_property, singledispatchmethod +from ape.utils import ( + BaseInterfaceModel, + ManagerAccessMixin, + cached_property, + log_instead_of_fail, + singledispatchmethod, +) from ape.utils.abi import StructParser from ape.utils.basemodel import _assert_not_ipython_check @@ -46,6 +52,7 @@ def __init__( logger.warning("Deploying an empty contract (no bytecode)") self.deployment_bytecode = HexBytes("") + @log_instead_of_fail(default="") def __repr__(self) -> str: return self.abi.signature if self.abi else "constructor()" @@ -87,6 +94,7 @@ def __init__(self, abi: MethodABI, address: AddressType) -> None: self.abi = abi self.address = address + @log_instead_of_fail(default="") def __repr__(self) -> str: return self.abi.signature @@ -124,6 +132,7 @@ def __init__(self, contract: "ContractInstance", abis: List[MethodABI]) -> None: self.contract = contract self.abis = abis + @log_instead_of_fail(default="") def __repr__(self) -> str: # `.method_name` return f"{self.contract.__repr__()}.{self.abis[-1].name}" @@ -268,6 +277,7 @@ def __init__(self, abi: MethodABI, address: AddressType) -> None: self.abi = abi self.address = address + @log_instead_of_fail(default="") def __repr__(self) -> str: return self.abi.signature @@ -386,7 +396,8 @@ class ContractEvent(BaseInterfaceModel): abi: EventABI _logs: Optional[List[ContractLog]] = None - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: return self.abi.signature @property @@ -892,6 +903,7 @@ def receipt(self) -> ReceiptAPI: self._cached_receipt = receipt return receipt + @log_instead_of_fail(default="") def __repr__(self) -> str: contract_name = self.contract_type.name or "Unnamed contract" return f"<{contract_name} {self.address}>" @@ -1239,6 +1251,7 @@ class ContractContainer(ContractTypeWrapper): def __init__(self, contract_type: ContractType) -> None: self.contract_type = contract_type + @log_instead_of_fail(default="") def __repr__(self) -> str: return f"<{self.contract_type.name}>" @@ -1451,6 +1464,7 @@ def __init__(self, name: str, contracts: List[ContractContainer]): self.name = name self.contracts = contracts + @log_instead_of_fail(default="") def __repr__(self) -> str: return f"<{self.name}>" diff --git a/src/ape/managers/accounts.py b/src/ape/managers/accounts.py index e6603647a5..6c4285f1e4 100644 --- a/src/ape/managers/accounts.py +++ b/src/ape/managers/accounts.py @@ -13,7 +13,7 @@ from ape.exceptions import ConversionError from ape.managers.base import BaseManager from ape.types import AddressType -from ape.utils import ManagerAccessMixin, cached_property, singledispatchmethod +from ape.utils import ManagerAccessMixin, cached_property, log_instead_of_fail, singledispatchmethod _DEFAULT_SENDERS: List[AccountAPI] = [] @@ -34,6 +34,7 @@ class TestAccountManager(list, ManagerAccessMixin): _impersonated_accounts: Dict[AddressType, ImpersonatedAccount] = {} + @log_instead_of_fail(default="") def __repr__(self) -> str: accounts_str = ", ".join([a.address for a in self.accounts]) return f"[{accounts_str}]" @@ -224,6 +225,7 @@ def __iter__(self) -> Iterator[AccountAPI]: for container in self.containers.values(): yield from container.accounts + @log_instead_of_fail(default="") def __repr__(self) -> str: return "[" + ", ".join(repr(a) for a in self) + "]" diff --git a/src/ape/managers/chain.py b/src/ape/managers/chain.py index 0c34fdf8da..115d60c54c 100644 --- a/src/ape/managers/chain.py +++ b/src/ape/managers/chain.py @@ -34,7 +34,13 @@ from ape.logging import logger from ape.managers.base import BaseManager from ape.types import AddressType, BlockID, CallTreeNode, SnapshotID, SourceTraceback -from ape.utils import BaseInterfaceModel, TraceStyles, nonreentrant, singledispatchmethod +from ape.utils import ( + BaseInterfaceModel, + TraceStyles, + log_instead_of_fail, + nonreentrant, + singledispatchmethod, +) class BlockContainer(BaseManager): @@ -1502,6 +1508,7 @@ def pending_timestamp(self) -> int: def pending_timestamp(self, new_value: str): self.provider.set_timestamp(self.conversion_manager.convert(value=new_value, type=int)) + @log_instead_of_fail(default="") def __repr__(self) -> str: props = f"id={self.chain_id}" if self.network_manager.active_provider else "disconnected" cls_name = getattr(type(self), "__name__", ChainManager.__name__) diff --git a/src/ape/managers/compilers.py b/src/ape/managers/compilers.py index 55501f76b2..ad1fc0a7ff 100644 --- a/src/ape/managers/compilers.py +++ b/src/ape/managers/compilers.py @@ -9,6 +9,7 @@ from ape.exceptions import ApeAttributeError, CompilerError, ContractLogicError from ape.logging import logger from ape.managers.base import BaseManager +from ape.utils import log_instead_of_fail from ape.utils.basemodel import _assert_not_ipython_check from ape.utils.os import get_relative_path @@ -28,7 +29,8 @@ class CompilerManager(BaseManager): _registered_compilers_cache: Dict[Path, Dict[str, CompilerAPI]] = {} - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: num_compilers = len(self.registered_compilers) cls_name = getattr(type(self), "__name__", CompilerManager.__name__) return f"<{cls_name} len(registered_compilers)={num_compilers}>" diff --git a/src/ape/managers/config.py b/src/ape/managers/config.py index 21b28ffe24..9b439b2736 100644 --- a/src/ape/managers/config.py +++ b/src/ape/managers/config.py @@ -9,7 +9,7 @@ from ape.api import ConfigDict, DependencyAPI, PluginConfig from ape.exceptions import ConfigError from ape.logging import logger -from ape.utils import BaseInterfaceModel, load_config +from ape.utils import BaseInterfaceModel, load_config, log_instead_of_fail if TYPE_CHECKING: from .project import ProjectManager @@ -262,7 +262,8 @@ def _plugin_configs(self) -> Dict[str, PluginConfig]: self._cached_configs[self._project_key] = configs return configs - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: return f"<{ConfigManager.__name__} project={self.PROJECT_FOLDER.name}>" def load(self, force_reload: bool = False) -> "ConfigManager": diff --git a/src/ape/managers/converters.py b/src/ape/managers/converters.py index c2773ccae3..79981be1f2 100644 --- a/src/ape/managers/converters.py +++ b/src/ape/managers/converters.py @@ -21,7 +21,7 @@ from ape.api.address import BaseAddress from ape.exceptions import ConversionError from ape.types import AddressType -from ape.utils import cached_property +from ape.utils import cached_property, log_instead_of_fail from .base import BaseManager @@ -217,7 +217,8 @@ class ConversionManager(BaseManager): amount = convert("1 gwei", int) """ - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: return f"<{ConversionManager.__name__}>" @cached_property diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index b542e54a38..3f97f31de6 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -14,7 +14,7 @@ ) from ape.managers.base import BaseManager from ape.utils.basemodel import _assert_not_ipython_check -from ape.utils.misc import _dict_overlay +from ape.utils.misc import _dict_overlay, log_instead_of_fail from ape_ethereum.provider import EthereumNodeProvider @@ -36,7 +36,8 @@ class NetworkManager(BaseManager): _active_provider: Optional[ProviderAPI] = None _default_ecosystem_name: Optional[str] = None - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: provider = self.active_provider class_name = NetworkManager.__name__ content = f"{class_name} active_provider={repr(provider)}" if provider else class_name diff --git a/src/ape/managers/project/dependency.py b/src/ape/managers/project/dependency.py index 1e20359931..32512e1879 100644 --- a/src/ape/managers/project/dependency.py +++ b/src/ape/managers/project/dependency.py @@ -17,6 +17,7 @@ cached_property, github_client, load_config, + log_instead_of_fail, pragma_str_to_specifier_set, ) @@ -234,7 +235,8 @@ def uri(self) -> AnyUrl: return HttpUrl(_uri) - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: cls_name = getattr(type(self), "__name__", GithubDependency.__name__) return f"<{cls_name} github={self.github}>" diff --git a/src/ape/managers/project/manager.py b/src/ape/managers/project/manager.py index 1184820652..2b8eed2d23 100644 --- a/src/ape/managers/project/manager.py +++ b/src/ape/managers/project/manager.py @@ -15,7 +15,7 @@ from ape.logging import logger from ape.managers.base import BaseManager from ape.managers.project.types import ApeProject, BrownieProject -from ape.utils import get_relative_path +from ape.utils import get_relative_path, log_instead_of_fail from ape.utils.basemodel import _assert_not_ipython_check @@ -57,13 +57,9 @@ def __init__( def __str__(self) -> str: return f'Project("{self.path}")' - def __repr__(self): - try: - path = f" {self.path}" - except Exception: - # Disallow exceptions in __repr__ - path = "" - + @log_instead_of_fail(default="") + def __repr__(self) -> str: + path = f" {self.path}" if self.path else "" return f"" @property diff --git a/src/ape/plugins/__init__.py b/src/ape/plugins/__init__.py index b6d299bba7..1012a31844 100644 --- a/src/ape/plugins/__init__.py +++ b/src/ape/plugins/__init__.py @@ -8,6 +8,7 @@ from ape.exceptions import ApeAttributeError from ape.logging import logger from ape.utils.basemodel import _assert_not_ipython_check +from ape.utils.misc import log_instead_of_fail from .account import AccountPlugin from .compiler import CompilerPlugin @@ -125,7 +126,8 @@ class PluginManager: def __init__(self) -> None: self.__registered = False - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: return f"<{PluginManager.__name__}>" def __getattr__(self, attr_name: str) -> Iterator[Tuple[str, Tuple]]: diff --git a/src/ape/plugins/_utils.py b/src/ape/plugins/_utils.py index b310cd3e26..3a221ff02a 100644 --- a/src/ape/plugins/_utils.py +++ b/src/ape/plugins/_utils.py @@ -13,7 +13,7 @@ from ape.__modules__ import __modules__ from ape.logging import logger from ape.plugins import clean_plugin_name -from ape.utils import BaseInterfaceModel, get_package_version, github_client +from ape.utils import BaseInterfaceModel, get_package_version, github_client, log_instead_of_fail from ape.utils.basemodel import BaseModel from ape.version import version as ape_version_str from ape_plugins.exceptions import PluginVersionError @@ -554,13 +554,9 @@ class PluginGroup(BaseModel): def __bool__(self) -> bool: return len(self.plugins) > 0 + @log_instead_of_fail(default="") def __repr__(self) -> str: - try: - return f"<{self.name} Plugins Group>" - except Exception: - # Prevent exceptions happening in repr() - logger.log_debug_stack_trace() - return "" + return f"<{self.name} Plugins Group>" def __str__(self) -> str: return self.to_str() @@ -621,14 +617,10 @@ def __init__( self.include = include or (PluginType.INSTALLED, PluginType.THIRD_PARTY) self.metadata = metadata + @log_instead_of_fail(default="") def __repr__(self) -> str: - try: - to_display_str = ", ".join([x.value for x in self.include]) - return f"" - except Exception: - # Prevent exceptions happening in repr() - logger.log_debug_stack_trace() - return "" + to_display_str = ", ".join([x.value for x in self.include]) + return f"" def __str__(self) -> str: sections = [] diff --git a/src/ape/types/__init__.py b/src/ape/types/__init__.py index 02c6b5457c..c8863a353b 100644 --- a/src/ape/types/__init__.py +++ b/src/ape/types/__init__.py @@ -51,7 +51,7 @@ ExtraModelAttributes, cached_property, ) -from ape.utils.misc import ZERO_ADDRESS, to_int +from ape.utils.misc import ZERO_ADDRESS, log_instead_of_fail, to_int if TYPE_CHECKING: from ape.api.providers import BlockAPI @@ -130,6 +130,7 @@ def from_str(cls, value: str) -> "ContractFunctionPath": def __str__(self) -> str: return f"{self.contract_name}:{self.method_name}" + @log_instead_of_fail(default="") def __repr__(self) -> str: return f"<{self}>" @@ -318,6 +319,7 @@ def _event_args_str(self) -> str: def __str__(self) -> str: return f"{self.event_name}({self._event_args_str})" + @log_instead_of_fail(default="") def __repr__(self) -> str: event_arg_str = self._event_args_str suffix = f" {event_arg_str}" if event_arg_str else "" diff --git a/src/ape/types/signatures.py b/src/ape/types/signatures.py index 8ffefcfdb6..6ec33d58d0 100644 --- a/src/ape/types/signatures.py +++ b/src/ape/types/signatures.py @@ -6,6 +6,8 @@ from eth_utils import to_bytes, to_hex from pydantic.dataclasses import dataclass +from ape.utils import log_instead_of_fail + try: # Only on Python 3.11 from typing import Self # type: ignore @@ -87,6 +89,7 @@ def from_vrs(cls, vrs: HexBytes) -> Self: return cls(v=vrs[0], r=HexBytes(vrs[1:33]), s=HexBytes(vrs[33:])) + @log_instead_of_fail(default="<_Signature>") def __repr__(self) -> str: class_name = getattr(type(self), "__name__", "_Signature") return f"<{class_name} v={self.v} r={to_hex(self.r)} s={to_hex(self.s)}>" diff --git a/src/ape/types/trace.py b/src/ape/types/trace.py index e248957cf9..68cb3367d8 100644 --- a/src/ape/types/trace.py +++ b/src/ape/types/trace.py @@ -13,7 +13,7 @@ from ape.types.address import AddressType from ape.utils.basemodel import BaseInterfaceModel -from ape.utils.misc import is_evm_precompile, is_zero_hex +from ape.utils.misc import is_evm_precompile, is_zero_hex, log_instead_of_fail from ape.utils.trace import _exclude_gas, parse_as_str, parse_gas_table, parse_rich_tree if TYPE_CHECKING: @@ -91,6 +91,7 @@ class CallTreeNode(BaseInterfaceModel): The raw tree, as a dictionary, associated with the call. """ + @log_instead_of_fail(default="") def __repr__(self) -> str: return parse_as_str(self) @@ -253,6 +254,7 @@ class ControlFlow(BaseModel): def __str__(self) -> str: return f"{self.source_header}\n{self.format()}" + @log_instead_of_fail(default="") def __repr__(self) -> str: source_name = f" {self.source_path.name} " if self.source_path is not None else " " representation = f" str: return self.format() + @log_instead_of_fail(default="") def __repr__(self) -> str: return f"" diff --git a/src/ape/utils/__init__.py b/src/ape/utils/__init__.py index 26c128d3ad..f8782b556a 100644 --- a/src/ape/utils/__init__.py +++ b/src/ape/utils/__init__.py @@ -36,6 +36,7 @@ is_evm_precompile, is_zero_hex, load_config, + log_instead_of_fail, nonreentrant, pragma_str_to_specifier_set, raises_not_implemented, @@ -99,6 +100,7 @@ "is_zero_hex", "JoinableQueue", "load_config", + "log_instead_of_fail", "LogInputABICollection", "ManagerAccessMixin", "nonreentrant", diff --git a/src/ape/utils/misc.py b/src/ape/utils/misc.py index 5668c215a8..27bed9544d 100644 --- a/src/ape/utils/misc.py +++ b/src/ape/utils/misc.py @@ -1,4 +1,5 @@ import asyncio +import functools import json import sys from asyncio import gather @@ -502,6 +503,31 @@ def _dict_overlay(mapping: Dict[str, Any], overlay: Dict[str, Any], depth: int = return mapping +def log_instead_of_fail(default: Optional[Any] = None): + """ + A decorator for logging errors instead of raising. + This is useful for methods like __repr__ which shouldn't fail. + """ + + def wrapper(fn): + @functools.wraps(fn) + def wrapped(*args, **kwargs): + try: + if args and isinstance(args[0], type): + return fn(*args, **kwargs) + else: + return fn(*args, **kwargs) + + except Exception as err: + logger.error(str(err)) + if default: + return default + + return wrapped + + return wrapper + + __all__ = [ "allow_disconnected", "cached_property", @@ -514,6 +540,7 @@ def _dict_overlay(mapping: Dict[str, Any], overlay: Dict[str, Any], depth: int = "is_evm_precompile", "is_zero_hex", "load_config", + "log_instead_of_fail", "nonreentrant", "raises_not_implemented", "run_until_complete", diff --git a/src/ape_accounts/accounts.py b/src/ape_accounts/accounts.py index 49ae0d2a3e..0e58756d5b 100644 --- a/src/ape_accounts/accounts.py +++ b/src/ape_accounts/accounts.py @@ -18,6 +18,7 @@ from ape.logging import logger from ape.types import AddressType, MessageSignature, SignableMessage, TransactionSignature from ape.utils.basemodel import ManagerAccessMixin +from ape.utils.misc import log_instead_of_fail from ape.utils.validators import _validate_account_alias, _validate_account_passphrase @@ -62,18 +63,11 @@ class KeyfileAccount(AccountAPI): __autosign: bool = False __cached_key: Optional[HexBytes] = None - def __repr__(self): + @log_instead_of_fail(default="") + def __repr__(self) -> str: # NOTE: Prevent errors from preventing repr from working. - try: - address_str = f" address={self.address} " - except Exception: - address_str = "" - - try: - alias_str = f" alias={self.alias} " - except Exception: - alias_str = "" - + address_str = f" address={self.address} " if self.address else "" + alias_str = f" alias={self.alias} " if self.alias else "" return f"<{self.__class__.__name__}{address_str}{alias_str}>" @property diff --git a/src/ape_geth/provider.py b/src/ape_geth/provider.py index 1dd5a1b56d..e963bc2df1 100644 --- a/src/ape_geth/provider.py +++ b/src/ape_geth/provider.py @@ -30,6 +30,7 @@ DEFAULT_TEST_MNEMONIC, JoinableQueue, generate_dev_accounts, + log_instead_of_fail, raises_not_implemented, spawn, ) @@ -245,11 +246,9 @@ def data_dir(self) -> Path: # Overridden from BaseGeth class for placing debug logs in ape data folder. return self.settings.data_dir or self.data_folder / self.name - def __repr__(self): - try: - return f"" - except Exception: - return "" + @log_instead_of_fail(default="") + def __repr__(self) -> str: + return f"" def connect(self): self._set_web3() diff --git a/tests/functional/test_transaction.py b/tests/functional/test_transaction.py index 3b6c448e3a..db85ce63d3 100644 --- a/tests/functional/test_transaction.py +++ b/tests/functional/test_transaction.py @@ -1,3 +1,4 @@ +import re import warnings import pytest @@ -303,6 +304,16 @@ def test_receipt_when_none(ethereum): assert txn.receipt is None +def test_repr(ethereum): + txn = ethereum.create_transaction(data=HexBytes("0x123")) + actual = repr(txn) + expected = ( + r"" + ) + assert re.match(expected, actual) + + # NOTE: Some of these values are needed for signing to work. @pytest.mark.parametrize( "tx_kwargs", diff --git a/tests/functional/utils/test_misc.py b/tests/functional/utils/test_misc.py index 17815df622..8182512c72 100644 --- a/tests/functional/utils/test_misc.py +++ b/tests/functional/utils/test_misc.py @@ -12,6 +12,7 @@ get_package_version, is_evm_precompile, is_zero_hex, + log_instead_of_fail, pragma_str_to_specifier_set, raises_not_implemented, run_until_complete, @@ -159,3 +160,12 @@ def test_dict_overlay(): assert "c" in mapping assert isinstance(mapping["c"], dict) assert "four" in mapping["c"] + + +def test_log_instead_of_fail(ape_caplog): + @log_instead_of_fail() + def my_method(): + raise ValueError("Oh no!") + + my_method() + assert "Oh no!" in ape_caplog.head diff --git a/tests/integration/cli/conftest.py b/tests/integration/cli/conftest.py index 33a5eda3bd..d437950dde 100644 --- a/tests/integration/cli/conftest.py +++ b/tests/integration/cli/conftest.py @@ -1,7 +1,7 @@ from contextlib import contextmanager -from distutils.dir_util import copy_tree from importlib import import_module from pathlib import Path +from shutil import copytree from typing import Dict, List, Optional import pytest @@ -102,7 +102,11 @@ def load(self, name: str) -> Path: project_source_dir = __projects_directory__ / name project_dest_dir = project_folder / project_source_dir.name - copy_tree(str(project_source_dir), str(project_dest_dir)) + project_dest_dir.parent.mkdir(exist_ok=True, parents=True) + + if not project_dest_dir.is_dir(): + copytree(str(project_source_dir), str(project_dest_dir)) + self.project_map[name] = project_dest_dir return self.project_map[name] diff --git a/tests/integration/cli/test_cache.py b/tests/integration/cli/test_cache.py index 4c1aa9c482..55378edc47 100644 --- a/tests/integration/cli/test_cache.py +++ b/tests/integration/cli/test_cache.py @@ -1,3 +1,7 @@ +from tests.integration.cli.utils import run_once + + +@run_once def test_cache_init_purge(ape_cli, runner): cmd = ("cache", "init", "--network", "ethereum:goerli") result = runner.invoke(ape_cli, cmd) From fcdb139557ad57b7fbe50cf2ff6250ecbde19e89 Mon Sep 17 00:00:00 2001 From: antazoey Date: Wed, 3 Apr 2024 12:43:54 -0600 Subject: [PATCH 05/24] docs: fix link and spelling issues in `ape init` command (#1987) --- src/ape_init/_cli.py | 46 +++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/ape_init/_cli.py b/src/ape_init/_cli.py index a804ce064a..788b7ebd99 100644 --- a/src/ape_init/_cli.py +++ b/src/ape_init/_cli.py @@ -4,58 +4,56 @@ import click from ape.cli import ape_cli_context +from ape.managers.config import CONFIG_FILE_NAME from ape.utils import github_client +GITIGNORE_CONTENT = """ +# Ape stuff +.build/ +.cache/ -@click.command(short_help="Initalize an ape project") +# Python +.env +.venv +.pytest_cache +.python-version +__pycache__ +""" + + +@click.command(short_help="Initialize an ape project") @ape_cli_context() @click.option("--github", metavar="github-org/repo", help="Clone a template from Github") def cli(cli_ctx, github): """ ``ape init`` allows the user to create an ape project with - default folders and ape-config.yaml - - From more information: - https://docs.apeworx.io/ape/stable/userguides/config.html + default folders and ape-config.yaml. """ if github: github_client.clone_repo(github, Path.cwd()) shutil.rmtree(Path.cwd() / ".git") - else: project_folder = Path.cwd() - for folder_name in ["contracts", "tests", "scripts"]: + for folder_name in ("contracts", "tests", "scripts"): # Create target Directory folder = project_folder / folder_name if folder.exists(): cli_ctx.logger.warning(f"'{folder}' exists") else: - folder.mkdir(exist_ok=False) + folder.mkdir() git_ignore_path = project_folder / ".gitignore" - if git_ignore_path.is_file(): + if git_ignore_path.exists(): cli_ctx.logger.warning(f"Unable to create .gitignore: '{git_ignore_path}' file exists.") else: - body = """ -# Ape stuff -.build/ -.cache/ - -# Python -.env -.venv -.pytest_cache -.python-version -__pycache__ -""" git_ignore_path.touch() - git_ignore_path.write_text(body.lstrip()) + git_ignore_path.write_text(GITIGNORE_CONTENT.lstrip()) - ape_config = project_folder / "ape-config.yaml" + ape_config = project_folder / CONFIG_FILE_NAME if ape_config.exists(): cli_ctx.logger.warning(f"'{ape_config}' exists") else: project_name = click.prompt("Please enter project name") ape_config.write_text(f"name: {project_name}\n") - cli_ctx.logger.success(f"{project_name} is written in ape-config.yaml") + cli_ctx.logger.success(f"{project_name} is written in {CONFIG_FILE_NAME}") From 25b61a160ae34fd77abef91036eb92e63aafc1c7 Mon Sep 17 00:00:00 2001 From: antazoey Date: Wed, 3 Apr 2024 17:44:17 -0600 Subject: [PATCH 06/24] fix: issue preventing docs CI workflow from updating `gh-pages` branch (#1989) --- .github/workflows/docs.yaml | 3 +++ .github/workflows/draft.yaml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 96a81ec301..ede332fe25 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -12,6 +12,9 @@ jobs: docs: runs-on: ubuntu-latest + permissions: + contents: write + steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/draft.yaml b/.github/workflows/draft.yaml index 423582b6c5..f136f49c7e 100644 --- a/.github/workflows/draft.yaml +++ b/.github/workflows/draft.yaml @@ -8,6 +8,8 @@ on: jobs: update-draft: runs-on: ubuntu-latest + permissions: + contents: write steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v5 From 4c9ea14fa2f2d933fedec60fb174bb484eb2e35b Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Thu, 4 Apr 2024 18:16:33 +0400 Subject: [PATCH 07/24] feat: make dynamic structs pickleable with this one simple trick (#1990) Co-authored-by: Juliya Smith --- src/ape/utils/abi.py | 8 +++-- tests/functional/utils/test_abi.py | 51 ++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/ape/utils/abi.py b/src/ape/utils/abi.py index 6d6a9d1c78..ff055f994d 100644 --- a/src/ape/utils/abi.py +++ b/src/ape/utils/abi.py @@ -312,11 +312,11 @@ def contains(struct, key): return key in struct.__dataclass_fields__ def is_equal(struct, other) -> bool: - _len = len(other) if not hasattr(other, "__len__"): return NotImplemented - elif _len != len(struct): + _len = len(other) + if _len != len(struct): return False if hasattr(other, "items"): @@ -355,6 +355,9 @@ def items(struct) -> List[Tuple]: def values(struct) -> List[Any]: return [x[1] for x in struct.items()] + def reduce(struct) -> tuple: + return (create_struct, (name, types, output_values)) + # NOTE: Should never be "_{i}", but mypy complains and we need a unique value properties = [m.name or f"_{i}" for i, m in enumerate(types)] methods = { @@ -363,6 +366,7 @@ def values(struct) -> List[Any]: "__setitem__": set_item, "__contains__": contains, "__len__": length, + "__reduce__": reduce, "items": items, "values": values, } diff --git a/tests/functional/utils/test_abi.py b/tests/functional/utils/test_abi.py index 260529447e..f0cf07b301 100644 --- a/tests/functional/utils/test_abi.py +++ b/tests/functional/utils/test_abi.py @@ -1,8 +1,11 @@ +import pickle +from copy import deepcopy + import pytest from eth_pydantic_types import HexBytes -from ethpm_types.abi import EventABI, EventABIType +from ethpm_types.abi import ABIType, EventABI, EventABIType -from ape.utils.abi import LogInputABICollection +from ape.utils.abi import LogInputABICollection, create_struct @pytest.fixture @@ -79,3 +82,47 @@ def test_decoding_with_strict(collection, topics, log_data_missing_trailing_zero "stakingLimit": 0, } assert actual == expected + + +class TestStruct: + @pytest.fixture + def struct(self): + return create_struct( + "MyStruct", (ABIType(name="proptest", type="string"),), ("output_value_0",) + ) + + def test_get_and_set_item(self, struct): + assert struct["proptest"] == "output_value_0" + struct["proptest"] = "something else" + assert struct["proptest"] == "something else" + + def test_is_equal(self, struct): + # Show struct equality works when props are the same. + assert struct == struct # even self + new_struct = deepcopy(struct) + assert struct == new_struct + # Show changing a property makes them unequal. + new_struct["proptest"] = "something else" + assert struct != new_struct + # Show testing with other types works w/o erroring. + assert struct != 47 + + def test_contains(self, struct): + assert "proptest" in struct + + def test_len(self, struct): + assert len(struct) == 1 + + def test_items(self, struct): + actual = struct.items() + assert len(actual) == 1 + assert actual[0] == ("proptest", "output_value_0") + + def test_values(self, struct): + actual = struct.values() + assert len(actual) == 1 + assert actual[0] == "output_value_0" + + def test_pickle(self, struct): + actual = pickle.dumps(struct) + assert isinstance(actual, bytes) From f5b6c7a91defeee74cc329479a8f76db24e35eab Mon Sep 17 00:00:00 2001 From: NotPeopling2day <32708219+NotPeopling2day@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:48:12 -0400 Subject: [PATCH 08/24] fix: plugin path assert (#1992) --- tests/functional/test_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/functional/test_plugins.py b/tests/functional/test_plugins.py index ee5098bb87..d27621a50f 100644 --- a/tests/functional/test_plugins.py +++ b/tests/functional/test_plugins.py @@ -167,7 +167,7 @@ def test_prepare_install(self): f"ape-available>=0.{ape_version.minor},<0.{ape_version.minor + 1}", "--quiet", ] - assert arguments[0].endswith("python") + assert "python" in arguments[0] assert arguments[1:] == expected def test_prepare_install_upgrade(self): @@ -183,7 +183,7 @@ def test_prepare_install_upgrade(self): f"ape-available>=0.{ape_version.minor},<0.{ape_version.minor + 1}", "--quiet", ] - assert arguments[0].endswith("python") + assert "python" in arguments[0] assert arguments[1:] == expected @mark_specifiers_less_than_ape From 5eeec421af32ed5a34e65e731f4401bdbc370205 Mon Sep 17 00:00:00 2001 From: antazoey Date: Thu, 11 Apr 2024 07:11:48 -0600 Subject: [PATCH 09/24] fix: remove usage of `pkg_resources` (#1996) --- setup.py | 2 +- src/ape/plugins/_utils.py | 107 +++++-------------------------- src/ape/utils/misc.py | 17 ++--- src/ape_plugins/_cli.py | 20 ++---- tests/functional/test_plugins.py | 74 ++++++++------------- 5 files changed, 51 insertions(+), 169 deletions(-) diff --git a/setup.py b/setup.py index 64e69944d3..a22449332e 100644 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ install_requires=[ "click>=8.1.6,<9", "ijson>=3.1.4,<4", - "importlib-metadata", + "importlib-metadata", # NOTE: Needed on 3.8 for entry_points `group=` kwarg. "ipython>=8.5.0,<9", "lazyasd>=0.1.4", "packaging>=23.0,<24", diff --git a/src/ape/plugins/_utils.py b/src/ape/plugins/_utils.py index 3a221ff02a..a29056825d 100644 --- a/src/ape/plugins/_utils.py +++ b/src/ape/plugins/_utils.py @@ -1,13 +1,11 @@ -import subprocess import sys from enum import Enum from functools import cached_property -from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple +from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple import click from packaging.specifiers import SpecifierSet from packaging.version import Version -from pkg_resources import working_set from pydantic import field_validator, model_validator from ape.__modules__ import __modules__ @@ -15,6 +13,7 @@ from ape.plugins import clean_plugin_name from ape.utils import BaseInterfaceModel, get_package_version, github_client, log_instead_of_fail from ape.utils.basemodel import BaseModel +from ape.utils.misc import _get_distributions from ape.version import version as ape_version_str from ape_plugins.exceptions import PluginVersionError @@ -111,53 +110,6 @@ class PluginType(Enum): """ -def _check_pip_freeze() -> str: - result = subprocess.check_output([sys.executable, "-m", "pip", "freeze"]) - return result.decode("utf8") - - -class _PipFreeze: - cache: Optional[Set[str]] = None - - def get_plugins(self, use_cache: bool = True, use_process: bool = False) -> Set[str]: - if use_cache and self.cache is not None: - return self.cache - - lines = [ - p - for p in ( - _check_pip_freeze().splitlines() - if use_process - else [str(x).replace(" ", "==") for x in working_set] - ) - if p.startswith("ape-") or (p.startswith("-e") and "ape-" in p) - ] - - # NOTE: Package IDs should look like "name==version" - # if version is available. - package_ids = [] - for package in lines: - if "-e" in package: - package_ids.append(package.split(".git")[0].split("/")[-1]) - elif "@" in package: - package_ids.append(package.split("@")[0].strip()) - elif "==" in package: - package_ids.append(package) - else: - package_ids.append(package) - - self.cache = set(x for x in package_ids) - return self.cache - - -_pip_freeze = _PipFreeze() - - -def _pip_freeze_plugins(use_cache: bool = True, use_process: bool = False): - # NOTE: In a method for mocking purposes in tests. - return _pip_freeze.get_plugins(use_cache=use_cache, use_process=use_process) - - class PluginMetadataList(BaseModel): """ Metadata per plugin type, including information for all plugins. @@ -175,14 +127,17 @@ def load(cls, plugin_manager): return cls.from_package_names(registered_plugins.union(available_plugins)) @classmethod - def from_package_names(cls, packages: Sequence[str]) -> "PluginMetadataList": + def from_package_names(cls, packages: Iterable[str]) -> "PluginMetadataList": PluginMetadataList.model_rebuild() core = PluginGroup(plugin_type=PluginType.CORE) available = PluginGroup(plugin_type=PluginType.AVAILABLE) installed = PluginGroup(plugin_type=PluginType.INSTALLED) third_party = PluginGroup(plugin_type=PluginType.THIRD_PARTY) - for name in {p for p in packages}: - plugin = PluginMetadata(name=name.strip()) + for package_id in packages: + parts = package_id.split("==") + name = parts[0] + version = parts[1] if len(parts) == 2 else None + plugin = PluginMetadata(name=name.strip(), version=version) if plugin.in_core: core.plugins[name] = plugin elif plugin.is_available and not plugin.is_installed: @@ -239,11 +194,6 @@ class PluginMetadata(BaseInterfaceModel): version: Optional[str] = None """The version requested, if there is one.""" - _use_subprocess_pip_freeze: bool = False - """ - Set to True if verifying changes. - """ - @model_validator(mode="before") @classmethod def validate_name(cls, values): @@ -366,29 +316,6 @@ def is_installed(self) -> bool: def is_third_party(self) -> bool: return self.is_installed and not self.is_available - @property - def pip_freeze_version(self) -> Optional[str]: - """ - The version from ``pip freeze`` output. - This is useful because when updating a plugin, it is not available - until the next Python session but you can use the property to - verify the update. - """ - - for package in _pip_freeze_plugins( - use_cache=False, use_process=self._use_subprocess_pip_freeze - ): - parts = package.split("==") - if len(parts) != 2: - continue - - name = parts[0] - if name == self.package_name: - version_str = parts[-1] - return version_str - - return None - @property def is_available(self) -> bool: """ @@ -408,12 +335,10 @@ def __str__(self): return f"{self.name}{version_key}" def check_installed(self, use_cache: bool = True): - ape_packages = [ - _split_name_and_version(n)[0] - for n in _pip_freeze_plugins( - use_cache=use_cache, use_process=self._use_subprocess_pip_freeze - ) - ] + if not use_cache: + _get_distributions.cache_clear() + + ape_packages = [n.name for n in _get_distributions()] return self.package_name in ape_packages def _prepare_install( @@ -483,7 +408,7 @@ def handle_install_result(self, result: int) -> bool: return False else: plugin_id = self._plugin.name - version = self._plugin.pip_freeze_version + version = self._plugin.version if version: # Sometimes, like in editable mode, the version is missing here. plugin_id = f"{plugin_id}=={version}" @@ -496,15 +421,15 @@ def handle_upgrade_result(self, result: int, version_before: str) -> bool: self._log_errors_occurred("upgrading") return False - version_now = self._plugin.pip_freeze_version + version_now = self._plugin.version if version_now is not None and version_before == version_now: logger.info(f"'{self._plugin.name}' already has version '{version_now}'.") return True - elif self._plugin.pip_freeze_version: + elif self._plugin.version: logger.success( f"Plugin '{self._plugin.name}' has been " - f"upgraded to version {self._plugin.pip_freeze_version}." + f"upgraded to version {self._plugin.version}." ) return True diff --git a/src/ape/utils/misc.py b/src/ape/utils/misc.py index 27bed9544d..181c0d645e 100644 --- a/src/ape/utils/misc.py +++ b/src/ape/utils/misc.py @@ -5,6 +5,8 @@ from asyncio import gather from datetime import datetime from functools import cached_property, lru_cache, singledispatchmethod, wraps +from importlib.metadata import PackageNotFoundError, distributions +from importlib.metadata import version as version_metadata from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, List, Mapping, Optional, cast @@ -12,8 +14,6 @@ import yaml from eth_pydantic_types import HexBytes from eth_utils import is_0x_prefixed -from importlib_metadata import PackageNotFoundError, distributions, packages_distributions -from importlib_metadata import version as version_metadata from packaging.specifiers import SpecifierSet from tqdm.auto import tqdm # type: ignore @@ -40,16 +40,7 @@ @lru_cache(maxsize=None) -def get_distributions(): - """ - Get a mapping of top-level packages to their distributions. - """ - - return packages_distributions() - - -@lru_cache(maxsize=None) -def _get_distributions(pkg_name: str) -> List: +def _get_distributions(pkg_name: Optional[str] = None) -> List: """ Get a mapping of top-level packages to their distributions. """ @@ -59,7 +50,7 @@ def _get_distributions(pkg_name: str) -> List: for dist in all_distros: package_names = (dist.read_text("top_level.txt") or "").split() for name in package_names: - if name == pkg_name: + if pkg_name is None or name == pkg_name: distros.append(dist) return distros diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index 3e0f13c6ad..3a28d4ff3b 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -14,10 +14,10 @@ PluginMetadata, PluginMetadataList, PluginType, - _pip_freeze, ape_version, ) from ape.utils import load_config +from ape.utils.misc import _get_distributions @click.group(short_help="Manage ape plugins") @@ -40,11 +40,7 @@ def load_from_file(ctx, file_path: Path) -> List[PluginMetadata]: if file_path.is_file(): config = load_config(file_path) if plugins := config.get("plugins"): - res = [PluginMetadata.model_validate(d) for d in plugins] - for itm in res: - itm._use_subprocess_pip_freeze = True - - return res + return [PluginMetadata.model_validate(d) for d in plugins] ctx.obj.logger.warning(f"No plugins found at '{file_path}'.") return [] @@ -60,17 +56,11 @@ def callback(ctx, param, value: Tuple[str]): res = ( load_from_file(ctx, file_path) if file_path.exists() - else [ - PluginMetadata(name=v, _use_subprocess_pip_freeze=True) - for v in value[0].split(" ") - ] + else [PluginMetadata(name=v) for v in value[0].split(" ")] ) else: - res = [PluginMetadata(name=v, _use_subprocess_pip_freeze=True) for v in value] - - for itm in res: - itm._use_subprocess_pip_freeze = True + res = [PluginMetadata(name=v) for v in value] return res @@ -242,7 +232,7 @@ 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. - for plugin in _pip_freeze.get_plugins(use_process=True): + for plugin in _get_distributions(): logger.info(f"Updating {plugin} ...") name = plugin.split("=")[0].strip() subprocess.call([sys.executable, "-m", "pip", "install", f"{name}{spec}", "--quiet"]) diff --git a/tests/functional/test_plugins.py b/tests/functional/test_plugins.py index d27621a50f..f0d3bcda37 100644 --- a/tests/functional/test_plugins.py +++ b/tests/functional/test_plugins.py @@ -10,7 +10,6 @@ PluginMetadata, PluginMetadataList, PluginType, - _PipFreeze, ape_version, ) from ape_plugins.exceptions import PluginVersionError @@ -27,42 +26,36 @@ ) -def get_pip_freeze_output(version: str): - return f"FOOFOO==1.1.1\n-e git+ssh://git@github.com/ApeWorX/ape-{INSTALLED_PLUGINS[0]}.git@aaaaaaaabbbbbb3343#egg=ape-{INSTALLED_PLUGINS[0]}\naiohttp==3.8.5\nape-{THIRD_PARTY[0]}=={version}\n" # noqa: E501 - - @pytest.fixture(autouse=True) -def mock_pip_freeze(mocker): +def mock_installed_packages(mocker): + def make_dist(name, version): + mock_dist = mocker.MagicMock() + mock_dist.name = name + mock_dist.version = version + return mock_dist + def fn(version: str): - patch = mocker.patch("ape.plugins._utils._check_pip_freeze") - patch.return_value = get_pip_freeze_output(version) + patch = mocker.patch("ape.plugins._utils._get_distributions") + patch.return_value = ( + make_dist("FOOFOO", "1.1.1"), + make_dist(f"ape-{INSTALLED_PLUGINS[0]}", version), + make_dist("aiohttp", "3.8.5"), + make_dist(f"ape-{THIRD_PARTY[0]}", version), + ) return patch return fn @pytest.fixture(autouse=True) -def plugin_test_env(mocker, mock_pip_freeze): +def plugin_test_env(mocker, mock_installed_packages): root = "ape.plugins._utils" # Prevent calling out to GitHub gh_mock = mocker.patch(f"{root}._get_available_plugins") gh_mock.return_value = {f"ape_{x}" for x in AVAILABLE_PLUGINS} - # Used when testing PipFreeze object itself but also extra avoids - # actually calling out pip ever in tests. - mock_pip_freeze(ape_version.base) - - # Prevent requiring plugins to be installed. - installed_mock = mocker.patch(f"{root}._pip_freeze_plugins") - installed_mock.return_value = { - f"ape-{INSTALLED_PLUGINS[0]}", - f"ape-{INSTALLED_PLUGINS[1]}=={ape_version.base}", - } - - # Prevent version lookups. - version_mock = mocker.patch(f"{root}.get_package_version") - version_mock.return_value = ape_version.base + mock_installed_packages(ape_version.base) @pytest.fixture @@ -74,7 +67,12 @@ def package_names() -> Set[str]: @pytest.fixture def plugin_metadata(package_names) -> PluginMetadataList: - return PluginMetadataList.from_package_names(package_names) + names = {x for x in package_names} + names.remove("ape-installed") + names.add(f"ape-installed==0.{ape_version.minor}.0") + names.remove("ape-thirdparty") + names.add(f"ape-thirdparty==0.{ape_version.minor}.0") + return PluginMetadataList.from_package_names(names) class TestPluginMetadataList: @@ -265,17 +263,8 @@ def test_repr_when_exception(self, mocker): assert repr(group) == "" -def test_pip_freeze_includes_version_when_available(): - pip_freeze = _PipFreeze() - actual = pip_freeze.get_plugins(use_process=True) - expected = {f"ape-{INSTALLED_PLUGINS[0]}", f"ape-{THIRD_PARTY[0]}==0.{ape_version.minor}.0"} - assert actual == expected - - def test_handle_upgrade_result_when_upgrading_to_same_version(caplog, logger): - # NOTE: pip freeze mock also returns version 0.{minor}.0 (so upgrade to same). - logger.set_level("INFO") # Required for test. - plugin = PluginMetadata(name=THIRD_PARTY[0]) + plugin = PluginMetadata(name=THIRD_PARTY[0], version=f"0.{ape_version.minor}.0") handler = ModifyPluginResultHandler(plugin) handler.handle_upgrade_result(0, f"0.{ape_version.minor}.0") if records := caplog.records: @@ -284,26 +273,13 @@ def test_handle_upgrade_result_when_upgrading_to_same_version(caplog, logger): in records[-1].message ) else: - version_at_end = plugin.pip_freeze_version + version_at_end = plugin.version pytest.fail( f"Missing logs when upgrading to same version 0.{ape_version.minor}.0. " - f"pip_freeze_version={version_at_end}" + f"version={version_at_end}" ) -def test_handle_upgrade_result_when_no_pip_freeze_version_does_not_log(caplog): - plugin_no_version = INSTALLED_PLUGINS[0] # Version not in pip-freeze - plugin = PluginMetadata(name=plugin_no_version) - handler = ModifyPluginResultHandler(plugin) - handler.handle_upgrade_result(0, f"0.{ape_version.minor}.0") - - log_parts = ("already has version", "already up to date") - messages = [x.message for x in caplog.records] - for message in messages: - for pt in log_parts: - assert pt not in message - - class TestApeVersion: def test_version_range(self): actual = ape_version.version_range From 2a435925290c67ceee9faa0875a8445c4d0c7206 Mon Sep 17 00:00:00 2001 From: Snoppy Date: Fri, 12 Apr 2024 01:09:38 +0800 Subject: [PATCH 10/24] chore: fix typos (#1997) --- src/ape/api/projects.py | 2 +- src/ape_ethereum/provider.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ape/api/projects.py b/src/ape/api/projects.py index c3f57c3f2b..1ace30d142 100644 --- a/src/ape/api/projects.py +++ b/src/ape/api/projects.py @@ -243,7 +243,7 @@ def add_compiler_data(self, compiler_data: Sequence[Compiler]) -> List[Compiler] *(matching_given_compiler.contractTypes or []), } ) - # NOTE: Purposely we don't add the exising compiler back, + # NOTE: Purposely we don't add the existing compiler back, # as it is the same as the given compiler, (meaning same # name, version, and settings), and we have # merged their contract types. diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index b6a78d64f1..51bc228836 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -1203,7 +1203,7 @@ def _handle_execution_reverted( ) enriched = self.compiler_manager.enrich_error(result) - # Show call trace if availble + # Show call trace if available if enriched.txn: # Unlikely scenario where a transaction is on the error even though a receipt exists. if isinstance(enriched.txn, TransactionAPI) and enriched.txn.receipt: From b6e3ececd78049ba8c72ee07e18d9c8b307d213c Mon Sep 17 00:00:00 2001 From: antazoey Date: Thu, 11 Apr 2024 16:56:37 -0600 Subject: [PATCH 11/24] fix: issues where "full extension" was not always honored (#1998) --- src/ape/managers/compilers.py | 23 ++++++++++++----------- src/ape/managers/project/manager.py | 15 +++++++++------ src/ape/pytest/coverage.py | 3 ++- src/ape/utils/os.py | 19 +++++++++++++++++++ src/ape_compile/_cli.py | 4 ++-- src/ape_ethereum/provider.py | 1 - tests/functional/utils/test_os.py | 20 +++++++++++++++++++- 7 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/ape/managers/compilers.py b/src/ape/managers/compilers.py index ad1fc0a7ff..c2466b8c2d 100644 --- a/src/ape/managers/compilers.py +++ b/src/ape/managers/compilers.py @@ -11,7 +11,7 @@ from ape.managers.base import BaseManager from ape.utils import log_instead_of_fail from ape.utils.basemodel import _assert_not_ipython_check -from ape.utils.os import get_relative_path +from ape.utils.os import get_full_extension, get_relative_path class CompilerManager(BaseManager): @@ -149,7 +149,7 @@ def compile( if path.is_file() and path not in paths_to_ignore and path not in already_compiled_paths - and path.suffix == extension + and get_full_extension(path) == extension and not any(x in [p.name for p in path.parents] for x in (".cache", ".build")) ] @@ -261,7 +261,9 @@ def get_imports( for ext, compiler in self.registered_compilers.items(): try: - sources = [p for p in contract_filepaths if p.suffix == ext and p.is_file()] + sources = [ + p for p in contract_filepaths if get_full_extension(p) == ext and p.is_file() + ] imports = compiler.get_imports(contract_filepaths=sources, base_path=base_path) except NotImplementedError: imports = None @@ -296,7 +298,7 @@ def get_references(self, imports_dict: Dict[str, List[str]]) -> Dict[str, List[s return references_dict def _get_contract_extensions(self, contract_filepaths: List[Path]) -> Set[str]: - extensions = {path.suffix for path in contract_filepaths} + extensions = {get_full_extension(path) for path in contract_filepaths} unhandled_extensions = {s for s in extensions - set(self.registered_compilers) if s} if len(unhandled_extensions) > 0: unhandled_extensions_str = ", ".join(unhandled_extensions) @@ -331,7 +333,7 @@ def enrich_error(self, err: ContractLogicError) -> ContractLogicError: # Contract or source not found. return err - ext = Path(contract.source_id).suffix + ext = get_full_extension(Path(contract.source_id)) if ext not in self.registered_compilers: # Compiler not found. return err @@ -351,12 +353,11 @@ def flatten_contract(self, path: Path, **kwargs) -> Content: ``ethpm_types.source.Content``: The flattened contract content. """ - if path.suffix not in self.registered_compilers: - raise CompilerError( - f"Unable to flatten contract. Missing compiler for '{path.suffix}'." - ) + ext = get_full_extension(path) + if ext not in self.registered_compilers: + raise CompilerError(f"Unable to flatten contract. Missing compiler for '{ext}'.") - compiler = self.registered_compilers[path.suffix] + compiler = self.registered_compilers[ext] return compiler.flatten_contract(path, **kwargs) def can_trace_source(self, filename: str) -> bool: @@ -375,7 +376,7 @@ def can_trace_source(self, filename: str) -> bool: if not path.is_file(): return False - extension = path.suffix + extension = get_full_extension(path) if extension in self.registered_compilers: compiler = self.registered_compilers[extension] if compiler.supports_source_tracing: diff --git a/src/ape/managers/project/manager.py b/src/ape/managers/project/manager.py index 2b8eed2d23..c7708053bc 100644 --- a/src/ape/managers/project/manager.py +++ b/src/ape/managers/project/manager.py @@ -17,6 +17,7 @@ from ape.managers.project.types import ApeProject, BrownieProject from ape.utils import get_relative_path, log_instead_of_fail from ape.utils.basemodel import _assert_not_ipython_check +from ape.utils.os import get_full_extension class ProjectManager(BaseManager): @@ -223,7 +224,7 @@ def _derive_settings(self) -> List[Compiler]: compiler_list: List[Compiler] = [] contracts_folder = self.config_manager.contracts_folder for ext, compiler in self.compiler_manager.registered_compilers.items(): - sources = [x for x in self.source_paths if x.is_file() and x.suffix == ext] + sources = [x for x in self.source_paths if x.is_file() and get_full_extension(x) == ext] if not sources: continue @@ -283,7 +284,9 @@ def tracked_deployments(self) -> Dict[Bip122Uri, Dict[str, EthPMContractInstance return deployments for ecosystem_path in [x for x in self._package_deployments_folder.iterdir() if x.is_dir()]: - for deployment_path in [x for x in ecosystem_path.iterdir() if x.suffix == ".json"]: + for deployment_path in [ + x for x in ecosystem_path.iterdir() if get_full_extension(x) == ".json" + ]: text = deployment_path.read_text() ethpm_instance = EthPMContractInstance.model_validate_json(text) if not ethpm_instance: @@ -407,7 +410,7 @@ def find_contracts_folder( continue if sub.is_file() and sub not in files_to_ignore: - if sub.suffix in extensions: + if get_full_extension(sub) in extensions: return sub.parent elif sub.is_dir(): @@ -642,7 +645,7 @@ def _append_extensions_in_dir(directory: Path): for file in directory.iterdir(): if file.is_dir(): _append_extensions_in_dir(file) - elif ext := file.suffix: + elif ext := get_full_extension(file): # NOTE: Also ignores files without extensions for simplicity. extensions_found.add(ext) @@ -677,7 +680,7 @@ def lookup_path(self, key_contract_path: Union[Path, str]) -> Optional[Path]: """ path = Path(key_contract_path) - ext = path.suffix or None + ext = get_full_extension(path) or None def find_in_dir(dir_path: Path) -> Optional[Path]: if not dir_path.is_dir(): @@ -688,7 +691,7 @@ def find_in_dir(dir_path: Path) -> Optional[Path]: return result # If the user provided an extension, it has to match. - ext_okay = ext == file_path.suffix if ext is not None else True + ext_okay = ext == get_full_extension(file_path) if ext is not None else True # File found if file_path.stem == path.stem and ext_okay: diff --git a/src/ape/pytest/coverage.py b/src/ape/pytest/coverage.py index 9d1a2855b6..0a6f40c71b 100644 --- a/src/ape/pytest/coverage.py +++ b/src/ape/pytest/coverage.py @@ -20,6 +20,7 @@ get_relative_path, parse_coverage_tables, ) +from ape.utils.os import get_full_extension class CoverageData(ManagerAccessMixin): @@ -48,7 +49,7 @@ def _init_coverage_profile( for src in self.sources: source_cov = project_coverage.include(src) - ext = Path(src.source_id).suffix + ext = get_full_extension(Path(src.source_id)) if ext not in self.compiler_manager.registered_compilers: continue diff --git a/src/ape/utils/os.py b/src/ape/utils/os.py index 3a0b70a859..6c1fc4ae70 100644 --- a/src/ape/utils/os.py +++ b/src/ape/utils/os.py @@ -139,3 +139,22 @@ def __exit__(self, *exc): for path in self.exclude: if path not in sys.path: sys.path.append(path) + + +def get_full_extension(path: Path) -> str: + """ + For a path like ``Path("Contract.t.sol")``, + returns ``.t.sol``, unlike the regular Path + property ``.suffix`` which returns ``.sol``. + """ + if path.is_dir(): + return "" + + parts = path.name.split(".") + start_idx = 2 if path.name.startswith(".") else 1 + + # NOTE: Handles when given just `.hiddenFile` since slice indices + # may exceed their bounds. + suffix = ".".join(parts[start_idx:]) + + return f".{suffix}" if suffix and f".{suffix}" != f"{path.name}" else "" diff --git a/src/ape_compile/_cli.py b/src/ape_compile/_cli.py index 29ad7cddac..e190acdb56 100644 --- a/src/ape_compile/_cli.py +++ b/src/ape_compile/_cli.py @@ -5,6 +5,7 @@ from ethpm_types import ContractType from ape.cli import ape_cli_context, contract_file_paths_argument +from ape.utils.os import get_full_extension def _include_dependencies_callback(ctx, param, value): @@ -51,7 +52,7 @@ def cli(cli_ctx, file_paths: Set[Path], use_cache: bool, display_size: bool, inc cli_ctx.logger.warning("Nothing to compile.") return - ext_given = [p.suffix for p in file_paths if p] + ext_given = [get_full_extension(p) for p in file_paths if p] # Filter out common files that we know are not files you can compile anyway, # like documentation files. NOTE: Nothing prevents a CompilerAPI from using these @@ -64,7 +65,6 @@ def cli(cli_ctx, file_paths: Set[Path], use_cache: bool, display_size: bool, inc for x in cli_ctx.project_manager.extensions_with_missing_compilers(ext_given) if x not in general_extensions } - if ext_with_missing_compilers: if len(ext_with_missing_compilers) > 1: # NOTE: `sorted` to increase reproducibility. diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index 51bc228836..6977de2528 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -1145,7 +1145,6 @@ def _handle_execution_reverted( contract_address: Optional[AddressType] = None, source_traceback: Optional[SourceTraceback] = None, ) -> ContractLogicError: - if hasattr(exception, "args") and len(exception.args) == 2: message = exception.args[0].replace("execution reverted: ", "") data = exception.args[1] diff --git a/tests/functional/utils/test_os.py b/tests/functional/utils/test_os.py index 20b93ec37e..d5474828ac 100644 --- a/tests/functional/utils/test_os.py +++ b/tests/functional/utils/test_os.py @@ -3,7 +3,7 @@ import pytest -from ape.utils.os import get_all_files_in_directory, get_relative_path +from ape.utils.os import get_all_files_in_directory, get_full_extension, get_relative_path _TEST_DIRECTORY_PATH = Path("/This/is/a/test/") _TEST_FILE_PATH = _TEST_DIRECTORY_PATH / "scripts" / "script.py" @@ -70,3 +70,21 @@ def test_get_all_files_in_directory(): assert len(txt_files) == 3 assert len(t_txt_files) == 1 assert len(inner_txt_files) == 1 + + +@pytest.mark.parametrize( + "path,expected", + [ + ("test.json", ".json"), + ("Contract.t.sol", ".t.sol"), + ("this.is.a.lot.of.dots", ".is.a.lot.of.dots"), + ("directory", ""), + (".privateDirectory", ""), + ("path/to/.gitkeep", ""), + (".config.json", ".json"), + ], +) +def test_get_full_extension(path, expected): + path = Path(path) + actual = get_full_extension(path) + assert actual == expected From 60d28294dff74e98e48761905ff17135d5ed05ae Mon Sep 17 00:00:00 2001 From: NotPeopling2day <32708219+NotPeopling2day@users.noreply.github.com> Date: Fri, 12 Apr 2024 11:44:20 -0400 Subject: [PATCH 12/24] fix: security update for eth-abi (#2002) --- .pre-commit-config.yaml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ecaa81366e..527de5a8cb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-yaml diff --git a/setup.py b/setup.py index a22449332e..1ebadff89e 100644 --- a/setup.py +++ b/setup.py @@ -117,7 +117,7 @@ "urllib3>=2.0.0,<3", "watchdog>=3.0,<4", # ** Dependencies maintained by Ethereum Foundation ** - "eth-abi>=4.2.1,<5", + "eth-abi>=5.1.0,<6", "eth-account>=0.10.0,<0.11", "eth-typing>=3.5.2,<4", "eth-utils>=2.3.1,<3", From 75294920e5a1b5400be88404cacb8308ab8ea1ed Mon Sep 17 00:00:00 2001 From: antazoey Date: Mon, 15 Apr 2024 20:04:57 -0600 Subject: [PATCH 13/24] chore: use dateutil type shed (#2005) --- setup.py | 1 + src/ape/managers/converters.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1ebadff89e..72de80f333 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,7 @@ "types-setuptools", # Needed due to mypy typeshed "pandas-stubs==1.2.0.62", # Needed due to mypy typeshed "types-SQLAlchemy>=1.4.49", # Needed due to mypy typeshed + "types-python-dateutil", # Needed due to mypy typeshed "flake8>=7.0.0,<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 diff --git a/src/ape/managers/converters.py b/src/ape/managers/converters.py index 79981be1f2..f93c7e6e4a 100644 --- a/src/ape/managers/converters.py +++ b/src/ape/managers/converters.py @@ -3,7 +3,7 @@ from decimal import Decimal from typing import Any, Dict, List, Sequence, Tuple, Type, Union -from dateutil.parser import parse # type: ignore +from dateutil.parser import parse from eth_pydantic_types import HexBytes from eth_typing.evm import ChecksumAddress from eth_utils import ( From 6f2172dc39fb479b249145775452eeb5a4e09d5a Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 09:18:05 -0600 Subject: [PATCH 14/24] fix: issue with kwargs for get_logs using eth-tester (#2006) --- setup.py | 12 +++++------ src/ape_ethereum/ecosystem.py | 14 ++++++++----- src/ape_ethereum/provider.py | 11 ++-------- src/ape_test/provider.py | 25 +++++++++++++++++++++-- tests/functional/test_block.py | 4 ++-- tests/functional/test_contract_event.py | 27 ++++++++++++------------- 6 files changed, 55 insertions(+), 38 deletions(-) diff --git a/setup.py b/setup.py index 72de80f333..eab553124b 100644 --- a/setup.py +++ b/setup.py @@ -123,13 +123,13 @@ "eth-typing>=3.5.2,<4", "eth-utils>=2.3.1,<3", "py-geth>=4.4.0,<5", - "web3[tester]>=6.16.0,<7", + "web3[tester]>=6.16.0,<6.17.1", # ** Dependencies maintained by ApeWorX ** - "eip712>=0.2.3,<0.4", - "ethpm-types>=0.6.7,<0.7", - "eth_pydantic_types>=0.1.0a5,<0.2", - "evmchains>=0.0.2,<0.1", - "evm-trace>=0.1.2", + "eip712>=0.2.7,<0.3", + "ethpm-types>=0.6.9,<0.7", + "eth_pydantic_types>=0.1.0,<0.2", + "evmchains>=0.0.6,<0.1", + "evm-trace>=0.1.3,<0.2", ], entry_points={ "console_scripts": ["ape=ape._cli:cli"], diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 4bfa2c80be..393dea3423 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -935,14 +935,18 @@ def get_abi(_topic: HexStr) -> Optional[LogInputABICollection]: converted_arguments[key] = value yield ContractLog( - block_hash=log["blockHash"], - block_number=log["blockNumber"], + block_hash=log.get("blockHash") or log.get("block_hash") or "", + block_number=log.get("blockNumber") or log.get("block_number") or 0, contract_address=self.decode_address(log["address"]), event_arguments=converted_arguments, event_name=abi.event_name, - log_index=log["logIndex"], - transaction_hash=log["transactionHash"], - transaction_index=log["transactionIndex"], + log_index=log.get("logIndex") or log.get("log_index") or 0, + transaction_hash=log.get("transactionHash") or log.get("transaction_hash") or "", + transaction_index=( + log.get("transactionIndex") + if "transactionIndex" in log + else log.get("transaction_index") + ), ) def enrich_calltree(self, call: CallTreeNode, **kwargs) -> CallTreeNode: diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index 6977de2528..286598b055 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -863,21 +863,14 @@ def fetch_log_page(block_range): start, stop = block_range update = {"start_block": start, "stop_block": stop} page_filter = log_filter.model_copy(update=update) - # eth-tester expects a different format, let web3 handle the conversions for it. - raw = "EthereumTester" not in self.client_version - logs = self._get_logs(page_filter.model_dump(mode="json"), raw) + filter_params = page_filter.model_dump(mode="json") + logs = self._make_request("eth_getLogs", [filter_params]) return self.network.ecosystem.decode_logs(logs, *log_filter.events) with ThreadPoolExecutor(self.concurrency) as pool: for page in pool.map(fetch_log_page, block_ranges): yield from page - def _get_logs(self, filter_params, raw=True) -> List[Dict]: - if not raw: - return [vars(d) for d in self.web3.eth.get_logs(filter_params)] - - return self._make_request("eth_getLogs", [filter_params]) - def prepare_transaction(self, txn: TransactionAPI) -> TransactionAPI: # NOTE: Use "expected value" for Chain ID, so if it doesn't match actual, we raise txn.chain_id = self.network.chain_id diff --git a/src/ape_test/provider.py b/src/ape_test/provider.py index 2085aa44fa..869295ae62 100644 --- a/src/ape_test/provider.py +++ b/src/ape_test/provider.py @@ -2,7 +2,7 @@ from ast import literal_eval from functools import cached_property from re import Pattern -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Iterator, Optional, cast from eth.exceptions import HeaderNotFound from eth_pydantic_types import HexBytes @@ -25,7 +25,7 @@ UnknownSnapshotError, VirtualMachineError, ) -from ape.types import BlockID, SnapshotID +from ape.types import BlockID, ContractLog, LogFilter, SnapshotID from ape.utils import DEFAULT_TEST_CHAIN_ID, DEFAULT_TEST_HD_PATH, gas_estimation_error_message from ape_ethereum.provider import Web3Provider @@ -268,6 +268,27 @@ def set_timestamp(self, new_timestamp: int): def mine(self, num_blocks: int = 1): self.evm_backend.mine_blocks(num_blocks) + def get_contract_logs(self, log_filter: LogFilter) -> Iterator[ContractLog]: + from_block = max(0, log_filter.start_block) + + if log_filter.stop_block is None: + to_block = None + else: + latest_block = self.get_block("latest").number + to_block = ( + min(latest_block, log_filter.stop_block) + if latest_block is not None + else log_filter.stop_block + ) + + log_gen = self.tester.ethereum_tester.get_logs( + address=log_filter.addresses, + from_block=from_block, + to_block=to_block, + topics=log_filter.topic_filter, + ) + yield from self.network.ecosystem.decode_logs(log_gen, *log_filter.events) + def get_virtual_machine_error(self, exception: Exception, **kwargs) -> VirtualMachineError: if isinstance(exception, ValidationError): match = self._CANNOT_AFFORD_GAS_PATTERN.match(str(exception)) diff --git a/tests/functional/test_block.py b/tests/functional/test_block.py index f80d533b6c..ff739cb2e9 100644 --- a/tests/functional/test_block.py +++ b/tests/functional/test_block.py @@ -17,7 +17,7 @@ def test_block_dict(block): "num_transactions": 0, "number": 0, "parentHash": block.parent_hash.hex(), - "size": 548, + "size": block.size, "timestamp": block.timestamp, "totalDifficulty": 0, "transactions": [], @@ -32,7 +32,7 @@ def test_block_json(block): f'"hash":"{block.hash.hex()}",' '"num_transactions":0,"number":0,' f'"parentHash":"{block.parent_hash.hex()}",' - f'"size":548,"timestamp":{block.timestamp},' + f'"size":{block.size},"timestamp":{block.timestamp},' f'"totalDifficulty":0,"transactions":[]}}' ) assert actual == expected diff --git a/tests/functional/test_contract_event.py b/tests/functional/test_contract_event.py index 6df308757b..a7566b55d6 100644 --- a/tests/functional/test_contract_event.py +++ b/tests/functional/test_contract_event.py @@ -4,7 +4,6 @@ import pytest from eth_pydantic_types import HexBytes -from eth_utils import to_hex from ethpm_types import ContractType from ape.api import ReceiptAPI @@ -124,7 +123,7 @@ def test_contract_logs_range(chain, contract_instance, owner, assert_log_values) def test_contract_logs_range_by_address( mocker, chain, eth_tester_provider, test_accounts, contract_instance, owner, assert_log_values ): - get_logs_spy = mocker.spy(eth_tester_provider.web3.eth, "get_logs") + get_logs_spy = mocker.spy(eth_tester_provider.tester.ethereum_tester, "get_logs") contract_instance.setAddress(test_accounts[1], sender=owner) height = chain.blocks.height logs = [ @@ -137,18 +136,18 @@ def test_contract_logs_range_by_address( # NOTE: This spy assertion tests against a bug where address queries were not # 0x-prefixed. However, this was still valid in EthTester and thus was not causing # test failures. - height_arg = to_hex(chain.blocks.height) - get_logs_spy.assert_called_once_with( - { - "address": [contract_instance.address], - "fromBlock": height_arg, - "toBlock": height_arg, - "topics": [ - "0x7ff7bacc6cd661809ed1ddce28d4ad2c5b37779b61b9e3235f8262be529101a9", - "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", - ], - } - ) + height_arg = chain.blocks.height + actual = get_logs_spy.call_args[-1] + expected = { + "address": [contract_instance.address], + "from_block": height_arg, + "to_block": height_arg, + "topics": [ + "0x7ff7bacc6cd661809ed1ddce28d4ad2c5b37779b61b9e3235f8262be529101a9", + "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + ], + } + assert actual == expected assert logs == [contract_instance.AddressChange(newAddress=test_accounts[1])] From d19499231b732b43c7bcb37b1a104b58bd160e8f Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 10:27:44 -0600 Subject: [PATCH 15/24] perf: replace pip freeze with importlib.metadata (#2004) Co-authored-by: El De-dog-lo <3859395+fubuloubu@users.noreply.github.com> --- src/ape/plugins/__init__.py | 32 ++++++++++---------------------- tests/functional/test_plugins.py | 16 ++++------------ 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/src/ape/plugins/__init__.py b/src/ape/plugins/__init__.py index 1012a31844..5cc46eeaa4 100644 --- a/src/ape/plugins/__init__.py +++ b/src/ape/plugins/__init__.py @@ -1,7 +1,6 @@ import functools import importlib -import pkgutil -import subprocess +from importlib.metadata import distributions from typing import Any, Callable, Generator, Iterator, List, Optional, Set, Tuple, Type from ape.__modules__ import __modules__ @@ -165,30 +164,19 @@ def registered_plugins(self) -> Set[str]: self._register_plugins() return {x[0] for x in pluggy_manager.list_name_plugin()} - @functools.cached_property - def _plugin_modules(self) -> Tuple[str, ...]: - # NOTE: Unable to use pkgutil.iter_modules() for installed plugins - # because it does not work with editable installs. - # See https://github.com/python/cpython/issues/99805. - result = subprocess.check_output( - ["pip", "list", "--format", "freeze", "--disable-pip-version-check"] - ) - packages = result.decode("utf8").splitlines() - installed_plugin_module_names = { - p.split("==")[0].replace("-", "_") for p in packages if p.startswith("ape-") - } - core_plugin_module_names = { - n for _, n, ispkg in pkgutil.iter_modules() if n.startswith("ape_") - } - - # NOTE: Returns tuple because this shouldn't change. - return tuple(installed_plugin_module_names.union(core_plugin_module_names)) - def _register_plugins(self): if self.__registered: return - for module_name in self._plugin_modules: + plugins = [ + x.name.replace("-", "_") + for x in distributions() + if getattr(x, "name", "").startswith("ape-") + ] + locals = [p for p in __modules__ if p != "ape"] + plugin_modules = tuple([*plugins, *locals]) + + for module_name in plugin_modules: try: module = importlib.import_module(module_name) pluggy_manager.register(module) diff --git a/tests/functional/test_plugins.py b/tests/functional/test_plugins.py index f0d3bcda37..79d22d9fdf 100644 --- a/tests/functional/test_plugins.py +++ b/tests/functional/test_plugins.py @@ -3,6 +3,7 @@ import pytest +from ape.logging import LogLevel from ape.plugins._utils import ( ApePluginsRepr, ModifyPluginResultHandler, @@ -263,21 +264,12 @@ def test_repr_when_exception(self, mocker): assert repr(group) == "" -def test_handle_upgrade_result_when_upgrading_to_same_version(caplog, logger): +def test_handle_upgrade_result_when_upgrading_to_same_version(ape_caplog, logger): plugin = PluginMetadata(name=THIRD_PARTY[0], version=f"0.{ape_version.minor}.0") handler = ModifyPluginResultHandler(plugin) + ape_caplog.set_levels(LogLevel.INFO) handler.handle_upgrade_result(0, f"0.{ape_version.minor}.0") - if records := caplog.records: - assert ( - f"'{THIRD_PARTY[0]}' already has version '0.{ape_version.minor}.0'" - in records[-1].message - ) - else: - version_at_end = plugin.version - pytest.fail( - f"Missing logs when upgrading to same version 0.{ape_version.minor}.0. " - f"version={version_at_end}" - ) + assert f"'{THIRD_PARTY[0]}' already has version '0.{ape_version.minor}.0'" in ape_caplog.head class TestApeVersion: From 25438b68df43daf6524a530ca7894f49b85997f8 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 13:50:25 -0600 Subject: [PATCH 16/24] fix: encoding nested struct calldata (#2009) --- src/ape/utils/abi.py | 9 +++- tests/functional/test_ecosystem.py | 87 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/ape/utils/abi.py b/src/ape/utils/abi.py index ff055f994d..68f578d855 100644 --- a/src/ape/utils/abi.py +++ b/src/ape/utils/abi.py @@ -91,7 +91,14 @@ def _encode(self, _type: ABIType, value: Any): and not isinstance(value, tuple) ): if isinstance(value, dict): - return tuple([value[m.name] for m in _type.components]) + return tuple( + ( + self._encode(m, value[m.name]) + if isinstance(value[m.name], dict) + else value[m.name] + ) + for m in _type.components + ) elif isinstance(value, (list, tuple)): # NOTE: Args must be passed in correct order. diff --git a/tests/functional/test_ecosystem.py b/tests/functional/test_ecosystem.py index ff8e7c1a8c..1280bf653e 100644 --- a/tests/functional/test_ecosystem.py +++ b/tests/functional/test_ecosystem.py @@ -153,6 +153,93 @@ def test_encode_calldata_byte_array(ethereum, sequence_type, item_type): assert isinstance(actual, bytes) +def test_encode_calldata_nested_structs(ethereum): + abi = MethodABI( + type="function", + name="check", + stateMutability="view", + inputs=[ + ABIType( + name="data", + type="tuple", + components=[ + ABIType( + name="tokenIn", type="address", components=None, internal_type="address" + ), + ABIType( + name="amountIn", type="uint256", components=None, internal_type="uint256" + ), + ABIType( + name="minAmountOut", + type="uint256", + components=None, + internal_type="uint256", + ), + ABIType( + name="path", + type="tuple", + components=[ + ABIType( + name="pairBinSteps", + type="uint256[]", + components=None, + internal_type="uint256[]", + ), + ABIType( + name="versions", + type="uint8[]", + components=None, + internal_type="enum SwapWrapper.Version[]", + ), + ABIType( + name="tokenPath", + type="address[]", + components=None, + internal_type="address[]", + ), + ], + internal_type="struct SwapWrapper.Path", + ), + ], + internal_type="struct SwapWrapper.SwapData", + ) + ], + outputs=[ABIType(name="", type="bool", components=None, internal_type="bool")], + ) + calldata = { + "tokenIn": "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", + "amountIn": 1000000000000000000, + "minAmountOut": 10000000, + "path": { + "pairBinSteps": [0], + "versions": [0], + "tokenPath": [ + "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", + "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + ], + }, + } + actual = ethereum.encode_calldata(abi, calldata) + expected = HexBytes( + "0x000000000000000000000000000000000000000000000000000000000000002" + "0000000000000000000000000b31f66aa3c1e785363f0875a1b74e27b85fd66c7" + "0000000000000000000000000000000000000000000000000de0b6b3a76400000" + "00000000000000000000000000000000000000000000000000000000098968000" + "00000000000000000000000000000000000000000000000000000000000080000" + "00000000000000000000000000000000000000000000000000000000000600000" + "0000000000000000000000000000000000000000000000000000000000a000000" + "000000000000000000000000000000000000000000000000000000000e0000000" + "00000000000000000000000000000000000000000000000000000000010000000" + "00000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000001000000000" + "00000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000200000000000" + "0000000000000b31f66aa3c1e785363f0875a1b74e27b85fd66c7000000000000" + "000000000000b97ef9ef8734c71904d8002f8b6bc66dd9c48a6e" + ) + assert actual == expected + + def test_block_handles_snake_case_parent_hash(eth_tester_provider, sender, receiver): # Transaction to change parent hash of next block sender.transfer(receiver, "1 gwei") From 9dea7d9714bf4a8183b87c28f48dce20b498f675 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 14:05:45 -0600 Subject: [PATCH 17/24] fix: field method name clash structs (#2010) --- src/ape/utils/abi.py | 9 +++++++++ tests/functional/utils/test_abi.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/src/ape/utils/abi.py b/src/ape/utils/abi.py index 68f578d855..72434b9006 100644 --- a/src/ape/utils/abi.py +++ b/src/ape/utils/abi.py @@ -378,6 +378,15 @@ def reduce(struct) -> tuple: "values": values, } + if conflicts := [p for p in properties if p in methods]: + conflicts_str = ", ".join(conflicts) + logger.debug( + "The following methods are unavailable on the struct " + f"due to having the same name as a field: {conflicts_str}" + ) + for conflict in conflicts: + del methods[conflict] + struct_def = make_dataclass( name, properties, diff --git a/tests/functional/utils/test_abi.py b/tests/functional/utils/test_abi.py index f0cf07b301..d4aabfc537 100644 --- a/tests/functional/utils/test_abi.py +++ b/tests/functional/utils/test_abi.py @@ -126,3 +126,9 @@ def test_values(self, struct): def test_pickle(self, struct): actual = pickle.dumps(struct) assert isinstance(actual, bytes) + + def test_field_with_same_name_as_method(self): + struct = create_struct( + "MyStruct", (ABIType(name="values", type="string"),), ("output_value_0",) + ) + assert struct.values == "output_value_0" # Is the field, not the method. From 4776fb1a2284a35df9a01ac51ca8433c34c16368 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 15:11:43 -0600 Subject: [PATCH 18/24] perf: make `ape --help` faster (#2011) --- src/ape/_cli.py | 7 +++++-- src/ape/plugins/_utils.py | 26 ++++++++++++++++++-------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/ape/_cli.py b/src/ape/_cli.py index 86ad87caa3..a4d85abfb9 100644 --- a/src/ape/_cli.py +++ b/src/ape/_cli.py @@ -52,11 +52,14 @@ def format_commands(self, ctx, formatter) -> None: "Plugin": [], "3rd-Party Plugin": [], } - metadata = PluginMetadataList.load(ManagerAccessMixin.plugin_manager) + + pl_metadata = PluginMetadataList.load( + ManagerAccessMixin.plugin_manager, include_available=False + ) for cli_name, cmd in commands: help = cmd.get_short_help_str(limit) - plugin = metadata.get_plugin(cli_name) + plugin = pl_metadata.get_plugin(cli_name) if not plugin: continue diff --git a/src/ape/plugins/_utils.py b/src/ape/plugins/_utils.py index a29056825d..3c42581e11 100644 --- a/src/ape/plugins/_utils.py +++ b/src/ape/plugins/_utils.py @@ -121,13 +121,17 @@ class PluginMetadataList(BaseModel): third_party: "PluginGroup" @classmethod - def load(cls, plugin_manager): - registered_plugins = plugin_manager.registered_plugins - available_plugins = github_client.available_plugins - return cls.from_package_names(registered_plugins.union(available_plugins)) + def load(cls, plugin_manager, include_available: bool = True): + plugins = plugin_manager.registered_plugins + if include_available: + plugins = plugins.union(github_client.available_plugins) + + return cls.from_package_names(plugins, include_available=include_available) @classmethod - def from_package_names(cls, packages: Iterable[str]) -> "PluginMetadataList": + def from_package_names( + cls, packages: Iterable[str], include_available: bool = True + ) -> "PluginMetadataList": PluginMetadataList.model_rebuild() core = PluginGroup(plugin_type=PluginType.CORE) available = PluginGroup(plugin_type=PluginType.AVAILABLE) @@ -140,11 +144,17 @@ def from_package_names(cls, packages: Iterable[str]) -> "PluginMetadataList": plugin = PluginMetadata(name=name.strip(), version=version) if plugin.in_core: core.plugins[name] = plugin - elif plugin.is_available and not plugin.is_installed: + continue + + # perf: only check these once. + is_installed = plugin.is_installed + is_available = include_available and plugin.is_available + + if include_available and is_available and not is_installed: available.plugins[name] = plugin - elif plugin.is_installed and not plugin.in_core and not plugin.is_available: + elif is_installed and not plugin.in_core and not is_available: third_party.plugins[name] = plugin - elif plugin.is_installed: + elif is_installed: installed.plugins[name] = plugin else: logger.error(f"'{plugin.name}' is not a plugin.") From 724dec3c04470abd09862e836c850d66a7701a3b Mon Sep 17 00:00:00 2001 From: NotPeopling2day <32708219+NotPeopling2day@users.noreply.github.com> Date: Mon, 15 Apr 2024 21:31:52 -0400 Subject: [PATCH 19/24] refactor!: remove goerli and mumbai (#1993) --- docs/userguides/config.md | 2 +- docs/userguides/networks.md | 14 +-- docs/userguides/scripts.md | 8 +- docs/userguides/transactions.md | 108 +++++++++++------------ src/ape_ethereum/ecosystem.py | 2 - src/ape_geth/provider.py | 1 - tests/functional/conftest.py | 2 +- tests/functional/geth/test_provider.py | 20 ++--- tests/functional/test_config.py | 22 ++--- tests/functional/test_ecosystem.py | 5 +- tests/functional/test_network_api.py | 18 ++-- tests/functional/test_network_manager.py | 10 +-- tests/integration/cli/test_cache.py | 8 +- tests/integration/cli/test_networks.py | 20 +---- 14 files changed, 110 insertions(+), 130 deletions(-) diff --git a/docs/userguides/config.md b/docs/userguides/config.md index 0777ece80e..f922246c0f 100644 --- a/docs/userguides/config.md +++ b/docs/userguides/config.md @@ -72,7 +72,7 @@ deployments: mainnet: - contract_type: MyContract address: 0x5FbDB2315678afecb367f032d93F642f64180aa3 - goerli: + sepolia: - contract_type: MyContract address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 ``` diff --git a/docs/userguides/networks.md b/docs/userguides/networks.md index e0889d5ac7..a303607037 100644 --- a/docs/userguides/networks.md +++ b/docs/userguides/networks.md @@ -1,6 +1,6 @@ # 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 Goerli) and a provider (e.g. Eth-Tester, Geth, or Alchemy). +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, 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. @@ -195,7 +195,7 @@ To add a corresponding entry in `ape-etherscan` (assuming you are using `ape-eth etherscan: ethereum: rate_limit: 15 # Configure a rate limit that makes sense for retry logic. - + # The name of the entry is the same as your custom network! customnetwork: uri: https://custom.scan # URL used for showing transactions @@ -360,9 +360,9 @@ ethereum: # Most networks use 120 seconds (2 minutes). transaction_acceptance_timeout: 60 - # The amount of times to retry fetching a receipt. This is useful - # because decentralized systems may show the transaction accepted - # on some nodes but not on others, and potentially RPC requests + # The amount of times to retry fetching a receipt. This is useful + # because decentralized systems may show the transaction accepted + # on some nodes but not on others, and potentially RPC requests # won't return a receipt immediately after sending its transaction. # This config accounts for such delay. The default is `20`. max_receipt_retries: 10 @@ -371,13 +371,13 @@ ethereum: # estimates gas. Note: local networks tend to use "max" here # by default. gas_limit: auto - + # Base-fee multipliers are useful for times when the base fee changes # before a transaction is sent but after the base fee was derived, # thus causing rejection. A multiplier reduces the chance of # rejection. The default for live networks is `1.4` times the base fee. base_fee_multiplier: 1.2 - + # The block time helps Ape make decisions about # polling chain data. block_time: 10 diff --git a/docs/userguides/scripts.md b/docs/userguides/scripts.md index 8e14e41dc2..012942dd62 100644 --- a/docs/userguides/scripts.md +++ b/docs/userguides/scripts.md @@ -74,11 +74,11 @@ from ape.cli import ape_cli_context @click.command() @ape_cli_context() -def cli(cli_ctx): +def cli(cli_ctx): # There is no connection yet at this point. testnets = { - "ethereum": ["sepolia", "goerli"], - "polygon": ["mumbai"] + "ethereum": ["sepolia"], + "polygon": ["amoy"] } nm = cli_ctx.network_manager @@ -137,7 +137,7 @@ Without specifying `--network`, the script with connect to your default network. Else, specify the network using the `--network` flag: ```shell -ape run foobar --network polygon:mumbai:alchemy +ape run foobar --network polygon:amoy:alchemy ``` You can also change networks within the script using the `ProviderContextManager` (see examples in the CLI-script section above). diff --git a/docs/userguides/transactions.md b/docs/userguides/transactions.md index 7c7e7bb087..3c3371bfe1 100644 --- a/docs/userguides/transactions.md +++ b/docs/userguides/transactions.md @@ -40,14 +40,14 @@ assert receipt.sender == dev Deploying from [ape console](./console.html) allows you to interact with a contract in real time. You can also use the `--network` flag to connect a live network. ```bash -ape console --network ethereum:goerli:alchemy +ape console --network ethereum:sepolia:alchemy ``` This will launch an IPython shell: ```python In [1]: dev = accounts.load("dev") -In [2]: token = dev.deploy(project.Token) +In [2]: token = dev.deploy(project.Token) In [3]: token.contract_method_defined_in_contract() ``` @@ -194,58 +194,58 @@ The trace might look something like: ```bash Call trace for '0x43abb1fdadfdae68f84ce8cd2582af6ab02412f686ee2544aa998db662a5ef50' txn.origin=0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C -ContractA.methodWithoutArguments() -> 0x00..7a9c [469604 gas] -├── SYMBOL.supercluster(x=234444) -> [ -│ [23523523235235, 11111111111, 234444], -│ [ -│ 345345347789999991, -│ 99999998888882, -│ 345457847457457458457457457 -│ ], -│ [234444, 92222229999998888882, 3454], -│ [ -│ 111145345347789999991, -│ 333399998888882, -│ 234545457847457457458457457457 -│ ] -│ ] [461506 gas] -├── SYMBOL.methodB1(lolol="ice-cream", dynamo=345457847457457458457457457) [402067 gas] -│ ├── ContractC.getSomeList() -> [ -│ │ 3425311345134513461345134534531452345, -│ │ 111344445534535353, -│ │ 993453434534534534534977788884443333 -│ │ ] [370103 gas] -│ └── ContractC.methodC1( -│ windows95="simpler", -│ jamaica=345457847457457458457457457, -│ cardinal=ContractA -│ ) [363869 gas] -├── SYMBOL.callMe(blue=tx.origin) -> tx.origin [233432 gas] -├── SYMBOL.methodB2(trombone=tx.origin) [231951 gas] -│ ├── ContractC.paperwork(ContractA) -> ( -│ │ os="simpler", -│ │ country=345457847457457458457457457, -│ │ wings=ContractA -│ │ ) [227360 gas] -│ ├── ContractC.methodC1(windows95="simpler", jamaica=0, cardinal=ContractC) [222263 gas] -│ ├── ContractC.methodC2() [147236 gas] -│ └── ContractC.methodC2() [122016 gas] -├── ContractC.addressToValue(tx.origin) -> 0 [100305 gas] -├── SYMBOL.bandPractice(tx.origin) -> 0 [94270 gas] -├── SYMBOL.methodB1(lolol="lemondrop", dynamo=0) [92321 gas] -│ ├── ContractC.getSomeList() -> [ -│ │ 3425311345134513461345134534531452345, -│ │ 111344445534535353, -│ │ 993453434534534534534977788884443333 -│ │ ] [86501 gas] -│ └── ContractC.methodC1(windows95="simpler", jamaica=0, cardinal=ContractA) [82729 gas] -└── SYMBOL.methodB1(lolol="snitches_get_stiches", dynamo=111) [55252 gas] - ├── ContractC.getSomeList() -> [ - │ 3425311345134513461345134534531452345, - │ 111344445534535353, - │ 993453434534534534534977788884443333 - │ ] [52079 gas] - └── ContractC.methodC1(windows95="simpler", jamaica=111, cardinal=ContractA) [48306 gas] +ContractA.methodWithoutArguments() -> 0x00..7a9c [469604 gas] +├── SYMBOL.supercluster(x=234444) -> [ +│ [23523523235235, 11111111111, 234444], +│ [ +│ 345345347789999991, +│ 99999998888882, +│ 345457847457457458457457457 +│ ], +│ [234444, 92222229999998888882, 3454], +│ [ +│ 111145345347789999991, +│ 333399998888882, +│ 234545457847457457458457457457 +│ ] +│ ] [461506 gas] +├── SYMBOL.methodB1(lolol="ice-cream", dynamo=345457847457457458457457457) [402067 gas] +│ ├── ContractC.getSomeList() -> [ +│ │ 3425311345134513461345134534531452345, +│ │ 111344445534535353, +│ │ 993453434534534534534977788884443333 +│ │ ] [370103 gas] +│ └── ContractC.methodC1( +│ windows95="simpler", +│ jamaica=345457847457457458457457457, +│ cardinal=ContractA +│ ) [363869 gas] +├── SYMBOL.callMe(blue=tx.origin) -> tx.origin [233432 gas] +├── SYMBOL.methodB2(trombone=tx.origin) [231951 gas] +│ ├── ContractC.paperwork(ContractA) -> ( +│ │ os="simpler", +│ │ country=345457847457457458457457457, +│ │ wings=ContractA +│ │ ) [227360 gas] +│ ├── ContractC.methodC1(windows95="simpler", jamaica=0, cardinal=ContractC) [222263 gas] +│ ├── ContractC.methodC2() [147236 gas] +│ └── ContractC.methodC2() [122016 gas] +├── ContractC.addressToValue(tx.origin) -> 0 [100305 gas] +├── SYMBOL.bandPractice(tx.origin) -> 0 [94270 gas] +├── SYMBOL.methodB1(lolol="lemondrop", dynamo=0) [92321 gas] +│ ├── ContractC.getSomeList() -> [ +│ │ 3425311345134513461345134534531452345, +│ │ 111344445534535353, +│ │ 993453434534534534534977788884443333 +│ │ ] [86501 gas] +│ └── ContractC.methodC1(windows95="simpler", jamaica=0, cardinal=ContractA) [82729 gas] +└── SYMBOL.methodB1(lolol="snitches_get_stiches", dynamo=111) [55252 gas] + ├── ContractC.getSomeList() -> [ + │ 3425311345134513461345134534531452345, + │ 111344445534535353, + │ 993453434534534534534977788884443333 + │ ] [52079 gas] + └── ContractC.methodC1(windows95="simpler", jamaica=111, cardinal=ContractA) [48306 gas] ``` Additionally, you can view the traces of other transactions on your network. diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 393dea3423..83aecdd159 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -73,7 +73,6 @@ NETWORKS = { # chain_id, network_id "mainnet": (1, 1), - "goerli": (5, 5), "sepolia": (11155111, 11155111), } BLUEPRINT_HEADER = HexBytes("0xfe71") @@ -281,7 +280,6 @@ def _get_custom_network(self, name: str) -> NetworkConfig: class EthereumConfig(BaseEthereumConfig): mainnet: NetworkConfig = create_network_config(block_time=13) - goerli: NetworkConfig = create_network_config(block_time=15) sepolia: NetworkConfig = create_network_config(block_time=15) diff --git a/src/ape_geth/provider.py b/src/ape_geth/provider.py index e963bc2df1..57838f8beb 100644 --- a/src/ape_geth/provider.py +++ b/src/ape_geth/provider.py @@ -198,7 +198,6 @@ def wait(self, *args, **kwargs): class GethNetworkConfig(PluginConfig): # Make sure you are running the right networks when you try for these mainnet: Dict = {"uri": get_random_rpc("ethereum", "mainnet")} - goerli: Dict = {"uri": get_random_rpc("ethereum", "goerli")} sepolia: Dict = {"uri": get_random_rpc("ethereum", "sepolia")} # Make sure to run via `geth --dev` (or similar) local: Dict = {**DEFAULT_SETTINGS.copy(), "chain_id": DEFAULT_TEST_CHAIN_ID} diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 56997b7e2f..60449f4c0b 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -512,7 +512,7 @@ def use_debug(logger): @pytest.fixture def dummy_live_network(chain): original_network = chain.provider.network.name - chain.provider.network.name = "goerli" + chain.provider.network.name = "sepolia" yield chain.provider.network chain.provider.network.name = original_network diff --git a/tests/functional/geth/test_provider.py b/tests/functional/geth/test_provider.py index 42a0895818..16e07b1b2d 100644 --- a/tests/functional/geth/test_provider.py +++ b/tests/functional/geth/test_provider.py @@ -86,8 +86,8 @@ def test_repr_on_local_network_and_disconnected(networks): @geth_process_test def test_repr_on_live_network_and_disconnected(networks): - geth = networks.get_provider_from_choice("ethereum:goerli:geth") - assert repr(geth) == "" + geth = networks.get_provider_from_choice("ethereum:sepolia:geth") + assert repr(geth) == "" @geth_process_test @@ -106,8 +106,8 @@ def test_chain_id_when_connected(geth_provider): @geth_process_test def test_chain_id_live_network_not_connected(networks): - geth = networks.get_provider_from_choice("ethereum:goerli:geth") - assert geth.chain_id == 5 + geth = networks.get_provider_from_choice("ethereum:sepolia:geth") + assert geth.chain_id == 11155111 @geth_process_test @@ -132,12 +132,12 @@ def test_connect_wrong_chain_id(ethereum, geth_provider, web3_factory): start_network = geth_provider.network expected_error_message = ( f"Provider connected to chain ID '{geth_provider._web3.eth.chain_id}', " - "which does not match network chain ID '5'. " - "Are you connected to 'goerli'?" + "which does not match network chain ID '11155111'. " + "Are you connected to 'sepolia'?" ) try: - geth_provider.network = ethereum.get_network("goerli") + geth_provider.network = ethereum.get_network("sepolia") # Ensure when reconnecting, it does not use HTTP web3_factory.return_value = geth_provider._web3 @@ -151,15 +151,15 @@ def test_connect_wrong_chain_id(ethereum, geth_provider, web3_factory): def test_connect_to_chain_that_started_poa(mock_web3, web3_factory, ethereum): """ Ensure that when connecting to a chain that - started out as PoA, such as Goerli, we include + started out as PoA, such as Sepolia, we include the right middleware. Note: even if the chain is no longer PoA, we still need the middleware to fetch blocks during the PoA portion of the chain. """ mock_web3.eth.get_block.side_effect = ExtraDataLengthError - mock_web3.eth.chain_id = ethereum.goerli.chain_id + mock_web3.eth.chain_id = ethereum.sepolia.chain_id web3_factory.return_value = mock_web3 - provider = ethereum.goerli.get_provider("geth") + provider = ethereum.sepolia.get_provider("geth") provider.provider_settings = {"uri": "http://node.example.com"} # fake provider.connect() diff --git a/tests/functional/test_config.py b/tests/functional/test_config.py index e5227d09b1..b7efe2c1b6 100644 --- a/tests/functional/test_config.py +++ b/tests/functional/test_config.py @@ -95,26 +95,26 @@ def _create_deployments( def test_ethereum_network_configs(config, temp_config): - eth_config = {"ethereum": {"goerli": {"default_provider": "test"}}} + eth_config = {"ethereum": {"sepolia": {"default_provider": "test"}}} with temp_config(eth_config): actual = config.get_config("ethereum") - assert actual.goerli.default_provider == "test" + assert actual.sepolia.default_provider == "test" # Ensure that non-updated fields remain unaffected - assert actual.goerli.block_time == 15 + assert actual.sepolia.block_time == 15 def test_network_gas_limit_default(config): eth_config = config.get_config("ethereum") - assert eth_config.goerli.gas_limit == "auto" + assert eth_config.sepolia.gas_limit == "auto" assert eth_config.local.gas_limit == "max" -def _goerli_with_gas_limit(gas_limit: GasLimit) -> dict: +def _sepolia_with_gas_limit(gas_limit: GasLimit) -> dict: return { "ethereum": { - "goerli": { + "sepolia": { "default_provider": "test", "gas_limit": gas_limit, } @@ -124,12 +124,12 @@ def _goerli_with_gas_limit(gas_limit: GasLimit) -> dict: @pytest.mark.parametrize("gas_limit", ("auto", "max")) def test_network_gas_limit_string_config(gas_limit, config, temp_config): - eth_config = _goerli_with_gas_limit(gas_limit) + eth_config = _sepolia_with_gas_limit(gas_limit) with temp_config(eth_config): actual = config.get_config("ethereum") - assert actual.goerli.gas_limit == gas_limit + assert actual.sepolia.gas_limit == gas_limit # Local configuration is unaffected assert actual.local.gas_limit == "max" @@ -137,12 +137,12 @@ def test_network_gas_limit_string_config(gas_limit, config, temp_config): @pytest.mark.parametrize("gas_limit", (1234, "1234", 0x4D2, "0x4D2")) def test_network_gas_limit_numeric_config(gas_limit, config, temp_config): - eth_config = _goerli_with_gas_limit(gas_limit) + eth_config = _sepolia_with_gas_limit(gas_limit) with temp_config(eth_config): actual = config.get_config("ethereum") - assert actual.goerli.gas_limit == 1234 + assert actual.sepolia.gas_limit == 1234 # Local configuration is unaffected assert actual.local.gas_limit == "max" @@ -153,7 +153,7 @@ def test_network_gas_limit_invalid_numeric_string(config, temp_config): Test that using hex strings for a network's gas_limit config must be prefixed with '0x' """ - eth_config = _goerli_with_gas_limit("4D2") + eth_config = _sepolia_with_gas_limit("4D2") with pytest.raises(ValueError, match="Gas limit hex str must include '0x' prefix."): with temp_config(eth_config): pass diff --git a/tests/functional/test_ecosystem.py b/tests/functional/test_ecosystem.py index 1280bf653e..e9ad46de88 100644 --- a/tests/functional/test_ecosystem.py +++ b/tests/functional/test_ecosystem.py @@ -646,7 +646,7 @@ def test_gas_limit_local_networks(ethereum, network_name): def test_gas_limit_live_networks(ethereum): - network = ethereum.get_network("goerli") + network = ethereum.get_network("sepolia") assert network.gas_limit == "auto" @@ -860,7 +860,7 @@ def test_set_default_network_not_exists(temp_config, ethereum): def test_networks(ethereum): actual = ethereum.networks - for net in ("goerli", "sepolia", "mainnet", LOCAL_NETWORK_NAME): + for net in ("sepolia", "mainnet", LOCAL_NETWORK_NAME): assert net in actual assert isinstance(actual[net], NetworkAPI) @@ -870,7 +870,6 @@ def test_networks_includes_custom_networks( ): actual = ethereum.networks for net in ( - "goerli", "sepolia", "mainnet", LOCAL_NETWORK_NAME, diff --git a/tests/functional/test_network_api.py b/tests/functional/test_network_api.py index cd6f84c8b4..850ae9dc3d 100644 --- a/tests/functional/test_network_api.py +++ b/tests/functional/test_network_api.py @@ -9,7 +9,7 @@ def test_get_provider_when_not_found(ethereum): - name = "goerli-fork" + name = "sepolia-fork" network = ethereum.get_network(name) expected = f"No provider named 'test' in network '{name}' in ecosystem 'ethereum'.*" with pytest.raises(ProviderNotFoundError, match=expected): @@ -19,18 +19,18 @@ def test_get_provider_when_not_found(ethereum): @pytest.mark.parametrize("scheme", ("http", "https", "ws", "wss")) def test_get_provider_http(ethereum, scheme): uri = f"{scheme}://example.com" - network = ethereum.get_network("goerli") + network = ethereum.get_network("sepolia") actual = network.get_provider(uri) assert actual.uri == uri - assert actual.network.name == "goerli" + assert actual.network.name == "sepolia" def test_get_provider_ipc(ethereum): path = "path/to/geth.ipc" - network = ethereum.get_network("goerli") + network = ethereum.get_network("sepolia") actual = network.get_provider(path) assert actual.ipc_path == Path(path) - assert actual.network.name == "goerli" + assert actual.network.name == "sepolia" def test_get_provider_custom_network(custom_networks_config, ethereum): @@ -41,14 +41,14 @@ def test_get_provider_custom_network(custom_networks_config, ethereum): def test_block_times(ethereum): - assert ethereum.goerli.block_time == 15 + assert ethereum.sepolia.block_time == 15 def test_set_default_provider_not_exists(temp_config, ape_caplog, ethereum): bad_provider = "NOT_EXISTS" - expected = f"Provider '{bad_provider}' not found in network 'ethereum:goerli'." + expected = f"Provider '{bad_provider}' not found in network 'ethereum:sepolia'." with pytest.raises(NetworkError, match=expected): - ethereum.goerli.set_default_provider(bad_provider) + ethereum.sepolia.set_default_provider(bad_provider) def test_gas_limits(ethereum, config, project_with_source_files_contract): @@ -56,7 +56,7 @@ def test_gas_limits(ethereum, config, project_with_source_files_contract): Test the default gas limit configurations for local and live networks. """ _ = project_with_source_files_contract # Ensure use of project with default config - assert ethereum.goerli.gas_limit == "auto" + assert ethereum.sepolia.gas_limit == "auto" assert ethereum.local.gas_limit == "max" diff --git a/tests/functional/test_network_manager.py b/tests/functional/test_network_manager.py index 022d7016b6..9850f1f4e7 100644 --- a/tests/functional/test_network_manager.py +++ b/tests/functional/test_network_manager.py @@ -21,8 +21,6 @@ def __call__(self, *args, **kwargs) -> int: DEFAULT_CHOICES = { "::geth", "::test", - ":goerli", - ":goerli:geth", ":sepolia", ":sepolia:geth", ":local", @@ -31,8 +29,6 @@ def __call__(self, *args, **kwargs) -> int: "ethereum", "ethereum::test", "ethereum::geth", - "ethereum:goerli", - "ethereum:goerli:geth", "ethereum:sepolia", "ethereum:sepolia:geth", "ethereum:local", @@ -70,7 +66,7 @@ def fn(): @pytest.fixture def network_with_no_providers(ethereum): - network = ethereum.get_network("goerli-fork") + network = ethereum.get_network("sepolia-fork") default_provider = network.default_provider providers = network.__dict__["providers"] @@ -128,7 +124,7 @@ def test_get_network_choices_filter_provider(networks): def test_get_provider_when_no_default(network_with_no_providers): expected = f"No default provider for network '{network_with_no_providers.name}'" with pytest.raises(NetworkError, match=expected): - # Not provider installed out-of-the-box for goerli-fork network + # Not provider installed out-of-the-box for sepolia-fork network provider = network_with_no_providers.get_provider() assert not provider, f"Provider should be None but got '{provider.name}'" @@ -148,7 +144,7 @@ def test_repr_disconnected(networks_disconnected): assert repr(networks_disconnected) == "" assert repr(networks_disconnected.ethereum) == "" assert repr(networks_disconnected.ethereum.local) == "" - assert repr(networks_disconnected.ethereum.goerli) == "" + assert repr(networks_disconnected.ethereum.sepolia) == "" def test_get_provider_from_choice_custom_provider(networks_connected_to_tester): diff --git a/tests/integration/cli/test_cache.py b/tests/integration/cli/test_cache.py index 55378edc47..0ca45660e4 100644 --- a/tests/integration/cli/test_cache.py +++ b/tests/integration/cli/test_cache.py @@ -3,8 +3,8 @@ @run_once def test_cache_init_purge(ape_cli, runner): - cmd = ("cache", "init", "--network", "ethereum:goerli") + cmd = ("cache", "init", "--network", "ethereum:sepolia") result = runner.invoke(ape_cli, cmd) - assert result.output == "SUCCESS: Caching database initialized for ethereum:goerli.\n" - result = runner.invoke(ape_cli, ["cache", "purge", "--network", "ethereum:goerli"]) - assert result.output == "SUCCESS: Caching database purged for ethereum:goerli.\n" + assert result.output == "SUCCESS: Caching database initialized for ethereum:sepolia.\n" + result = runner.invoke(ape_cli, ["cache", "purge", "--network", "ethereum:sepolia"]) + assert result.output == "SUCCESS: Caching database purged for ethereum:sepolia.\n" diff --git a/tests/integration/cli/test_networks.py b/tests/integration/cli/test_networks.py index 597e82229a..4f8c7c3cc3 100644 --- a/tests/integration/cli/test_networks.py +++ b/tests/integration/cli/test_networks.py @@ -5,8 +5,6 @@ _DEFAULT_NETWORKS_TREE = """ ethereum (default) -├── goerli -│ └── geth (default) ├── local (default) │ ├── geth │ └── test (default) @@ -20,12 +18,6 @@ - isDefault: true name: ethereum networks: - - name: goerli - providers: - - isDefault: true - name: geth - - name: goerli-fork - providers: [] - isDefault: true name: local providers: @@ -47,8 +39,6 @@ """ _GETH_NETWORKS_TREE = """ ethereum (default) -├── goerli -│ └── geth (default) ├── local (default) │ └── geth (default) └── mainnet @@ -59,9 +49,9 @@ └── local (default) └── test (default) """ -_GOERLI_NETWORK_TREE_OUTPUT = """ +_SEPOLIA_NETWORK_TREE_OUTPUT = """ ethereum (default) -└── goerli +└── sepolia └── geth (default) """ _CUSTOM_NETWORKS_TREE = """ @@ -70,8 +60,6 @@ │ └── geth (default) ├── apenet1 │ └── geth (default) -├── goerli -│ └── geth (default) ├── local (default) │ └── geth (default) └── mainnet @@ -151,12 +139,12 @@ def test_list_geth(ape_cli, runner, networks, project): @run_once def test_list_filter_networks(ape_cli, runner, networks): - result = runner.invoke(ape_cli, ["networks", "list", "--network", "goerli"]) + result = runner.invoke(ape_cli, ["networks", "list", "--network", "sepolia"]) # Grab ethereum actual = "ethereum (default)\n" + "".join(result.output.split("ethereum (default)\n")[-1]) - assert_rich_text(actual, _GOERLI_NETWORK_TREE_OUTPUT) + assert_rich_text(actual, _SEPOLIA_NETWORK_TREE_OUTPUT) @run_once From 5d08d0a91b4dacdd863a64c23ad80e873edab622 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 15:17:39 -0600 Subject: [PATCH 20/24] fix!: handle missing size with computed lookup (#1980) --- src/ape/api/providers.py | 68 +++++++++++++++++++++++++++++++++- src/ape_ethereum/ecosystem.py | 24 +++++++++--- tests/functional/test_block.py | 39 ++++++++++++++++++- tests/functional/test_query.py | 2 +- 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index 944a9bf602..c44c29006a 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -57,17 +57,41 @@ class BlockAPI(BaseInterfaceModel): # NOTE: All fields in this class (and it's subclasses) should not be `Optional` # except the edge cases noted below + """ + The number of transactions in the block. + """ num_transactions: int = 0 + + """ + The block hash identifier. + """ hash: Optional[Any] = None # NOTE: pending block does not have a hash + + """ + The block number identifier. + """ number: Optional[int] = None # NOTE: pending block does not have a number + + """ + The preceeding block's hash. + """ parent_hash: Any = Field( EMPTY_BYTES32, alias="parentHash" ) # NOTE: genesis block has no parent hash - size: int + + """ + The timestamp the block was produced. + NOTE: The pending block uses the current timestamp. + """ timestamp: int + _size: Optional[int] = None + @property def datetime(self) -> datetime.datetime: + """ + The block timestamp as a datetime object. + """ return datetime.datetime.fromtimestamp(self.timestamp, tz=datetime.timezone.utc) @model_validator(mode="before") @@ -77,12 +101,54 @@ def convert_parent_hash(cls, data): data["parentHash"] = parent_hash return data + @model_validator(mode="wrap") + @classmethod + def validate_size(cls, values, handler): + """ + A validator for handling non-computed size. + Saves it to a private member on this class and + gets returned in computed field "size". + """ + + if not hasattr(values, "pop"): + # Handle weird AttributeDict missing pop method. + # https://github.com/ethereum/web3.py/issues/3326 + values = {**values} + + size = values.pop("size", None) + model = handler(values) + if size is not None: + model._size = size + + return model + @computed_field() # type: ignore[misc] @cached_property def transactions(self) -> List[TransactionAPI]: + """ + All transactions in a block. + """ query = BlockTransactionQuery(columns=["*"], block_id=self.hash) return cast(List[TransactionAPI], list(self.query_manager.query(query))) + @computed_field() # type: ignore[misc] + @cached_property + def size(self) -> int: + """ + The size of the block in gas. Most of the time, + this field is passed to the model at validation time, + but occassionally it is missing (like in `eth_subscribe:newHeads`), + in which case it gets calculated if and only if the user + requests it (or during serialization of this model to disk). + """ + + if self._size is not None: + # The size was provided with the rest of the model + # (normal). + return self._size + + raise APINotImplementedError() + class ProviderAPI(BaseInterfaceModel): """ diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 83aecdd159..8725bcbbf8 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -293,6 +293,7 @@ class Block(BlockAPI): base_fee: int = Field(0, alias="baseFeePerGas") difficulty: int = 0 total_difficulty: int = Field(0, alias="totalDifficulty") + uncles: List[HexBytes] = [] # Type re-declares. hash: Optional[HexBytes] = None @@ -315,6 +316,24 @@ class Block(BlockAPI): def validate_ints(cls, value): return to_int(value) if value else 0 + @computed_field() # type: ignore[misc] + @property + def size(self) -> int: + if self._size is not None: + # The size was provided with the rest of the model + # (normal). + return self._size + + # Try to get it from the provider. + if provider := self.network_manager.active_provider: + block = provider.get_block(self.number) + size = block._size + if size is not None and size > -1: + self._size = size + return size + + raise APINotImplementedError() + class Ethereum(EcosystemAPI): # NOTE: `default_transaction_type` should be overridden @@ -550,11 +569,6 @@ def decode_block(self, data: Dict) -> BlockAPI: if "transactions" in data: data["num_transactions"] = len(data["transactions"]) - if "size" not in data: - # NOTE: Due to an issue with `eth_subscribe:newHeads` on Infura - # https://github.com/ApeWorX/ape-infura/issues/72 - data["size"] = -1 # HACK: use an unrealistic sentinel value - return Block.model_validate(data) def _python_type_for_abi_type(self, abi_type: ABIType) -> Union[Type, Sequence]: diff --git a/tests/functional/test_block.py b/tests/functional/test_block.py index ff739cb2e9..ec68da0c0a 100644 --- a/tests/functional/test_block.py +++ b/tests/functional/test_block.py @@ -1,4 +1,7 @@ import pytest +from eth_pydantic_types import HexBytes + +from ape_ethereum.ecosystem import Block @pytest.fixture @@ -6,6 +9,13 @@ def block(chain): return chain.blocks.head +def test_block(eth_tester_provider, vyper_contract_instance): + data = eth_tester_provider.web3.eth.get_block("latest") + actual = Block.model_validate(data) + assert actual.hash == data["hash"] + assert actual.number == data["number"] + + def test_block_dict(block): actual = block.model_dump(mode="json") expected = { @@ -21,6 +31,7 @@ def test_block_dict(block): "timestamp": block.timestamp, "totalDifficulty": 0, "transactions": [], + "uncles": [], } assert actual == expected @@ -33,6 +44,32 @@ def test_block_json(block): '"num_transactions":0,"number":0,' f'"parentHash":"{block.parent_hash.hex()}",' f'"size":{block.size},"timestamp":{block.timestamp},' - f'"totalDifficulty":0,"transactions":[]}}' + f'"totalDifficulty":0,"transactions":[],"uncles":[]}}' ) assert actual == expected + + +def test_block_calculate_size(block): + original = block.model_dump(by_alias=True) + size = original.pop("size") + + # Show size works normally (validated when passed in as a field). + assert size > 0 + assert block.size == size + + # Show we can also calculate size if it is missing. + actual = block.model_validate(original) # re-init without size. + assert actual.size == size + + original["size"] = 123 + new_block = Block.model_validate(original) + assert new_block.size == 123 # Show no clashing. + assert actual.size == size # Show this hasn't changed. + + +def test_block_uncles(block): + data = block.model_dump(by_alias=True) + uncles = [HexBytes("0xb983ecae1ed260dd08d108653912a9138bdce56c78aa7d78ee4fca70c2c8767b")] + data["uncles"] = uncles + actual = Block.model_validate(data) + assert actual.uncles == uncles diff --git a/tests/functional/test_query.py b/tests/functional/test_query.py index 0c4a77c195..fad3e49806 100644 --- a/tests/functional/test_query.py +++ b/tests/functional/test_query.py @@ -29,9 +29,9 @@ def test_basic_query(chain, eth_tester_provider): "num_transactions", "number", "parent_hash", - "size", "timestamp", "total_difficulty", + "uncles", ] From 4e2e527d1d7dd1dc6ae7fefab3f8b751cf4c74ee Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 15:19:18 -0600 Subject: [PATCH 21/24] refactor: misc 08 changes (#1984) --- src/ape/api/providers.py | 4 +-- src/ape/cli/__init__.py | 3 +-- src/ape/cli/commands.py | 15 ----------- src/ape/managers/chain.py | 2 +- src/ape/managers/networks.py | 36 -------------------------- src/ape/pytest/config.py | 2 +- src/ape/pytest/plugin.py | 4 +-- src/ape_geth/provider.py | 2 +- src/ape_test/__init__.py | 28 +++++++++++++++----- src/ape_test/provider.py | 2 +- tests/functional/geth/test_provider.py | 4 +-- tests/functional/test_cli.py | 23 ---------------- tests/integration/cli/test_test.py | 10 ++++--- 13 files changed, 39 insertions(+), 96 deletions(-) diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index c44c29006a..e1df89057f 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -601,7 +601,7 @@ def snapshot(self) -> SnapshotID: # type: ignore[empty-body] """ @raises_not_implemented - def revert(self, snapshot_id: SnapshotID): + def restore(self, snapshot_id: SnapshotID): """ Defined to make the ``ProviderAPI`` interchangeable with a :class:`~ape.api.providers.TestProviderAPI`, as in @@ -844,7 +844,7 @@ def snapshot(self) -> SnapshotID: """ @abstractmethod - def revert(self, snapshot_id: SnapshotID): + def restore(self, snapshot_id: SnapshotID): """ Regress the current call using the given snapshot ID. Allows developers to go back to a previous state. diff --git a/src/ape/cli/__init__.py b/src/ape/cli/__init__.py index c3dd09ef97..171f90da75 100644 --- a/src/ape/cli/__init__.py +++ b/src/ape/cli/__init__.py @@ -13,7 +13,7 @@ output_format_choice, select_account, ) -from ape.cli.commands import ConnectedProviderCommand, NetworkBoundCommand +from ape.cli.commands import ConnectedProviderCommand from ape.cli.options import ( ApeCliContextObject, NetworkOption, @@ -42,7 +42,6 @@ "get_user_selected_account", "incompatible_with", "network_option", - "NetworkBoundCommand", "NetworkChoice", "NetworkOption", "non_existing_alias_argument", diff --git a/src/ape/cli/commands.py b/src/ape/cli/commands.py index c7ef6cd7f7..e7c6c39fe8 100644 --- a/src/ape/cli/commands.py +++ b/src/ape/cli/commands.py @@ -1,5 +1,4 @@ import inspect -import warnings from typing import Any, List, Optional import click @@ -131,17 +130,3 @@ def _invoke(self, ctx: Context, provider: Optional[ProviderAPI] = None): ctx.params["network"] = provider.network_choice return ctx.invoke(self.callback or (lambda: None), **ctx.params) - - -# TODO: 0.8 delete -class NetworkBoundCommand(ConnectedProviderCommand): - def __init__(self, *args, **kwargs): - warnings.warn( - "'NetworkBoundCommand' is deprecated. Use 'ConnectedProviderCommand'.", - DeprecationWarning, - ) - - # Disable the advanced network class types so it behaves legacy. - kwargs["use_cls_types"] = False - - super().__init__(*args, **kwargs) diff --git a/src/ape/managers/chain.py b/src/ape/managers/chain.py index 115d60c54c..e3f2afddbb 100644 --- a/src/ape/managers/chain.py +++ b/src/ape/managers/chain.py @@ -1558,7 +1558,7 @@ def restore(self, snapshot_id: Optional[SnapshotID] = None): snapshot_index = self._snapshots.index(snapshot_id) self._snapshots = self._snapshots[:snapshot_index] - self.provider.revert(snapshot_id) + self.provider.restore(snapshot_id) self.history.revert_to_block(self.blocks.height) @contextmanager diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index 3f97f31de6..c4828412d7 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -1,9 +1,6 @@ -import json from functools import cached_property from typing import Collection, Dict, Iterator, List, Optional, Set, Type, Union -import yaml - from ape.api import EcosystemAPI, ProviderAPI, ProviderContextManager from ape.api.networks import NetworkAPI from ape.exceptions import ( @@ -629,39 +626,6 @@ def _get_ecosystem_data( return ecosystem_data - @property - # TODO: Remove in 0.7 - def networks_yaml(self) -> str: - """ - Get a ``yaml`` ``str`` representing all the networks - in all the ecosystems. - **NOTE**: Deprecated. - - View the result via CLI command ``ape networks list --format yaml``. - - Returns: - str - """ - - data = self.network_data - if not isinstance(data, dict): - raise TypeError( - f"Unexpected network data type: {type(data)}. " - f"Expecting dict. YAML dump will fail." - ) - - try: - return yaml.dump(data, sort_keys=True) - except ValueError as err: - try: - data_str = json.dumps(data) - except Exception: - data_str = str(data) - - raise NetworkError( - f"Network data did not dump to YAML: {data_str}\nActual err: {err}" - ) from err - def _validate_filter(arg: Optional[Union[List[str], str]], options: Set[str]): filters = arg or [] diff --git a/src/ape/pytest/config.py b/src/ape/pytest/config.py index e0523bdd73..d2f3764ad7 100644 --- a/src/ape/pytest/config.py +++ b/src/ape/pytest/config.py @@ -69,7 +69,7 @@ def html_coverage(self) -> Union[bool, Dict]: @cached_property def show_internal(self) -> bool: - return self.pytest_config.getoption("showinternal") + return self.pytest_config.getoption("--show-internal") @cached_property def gas_exclusions(self) -> List[ContractFunctionPath]: diff --git a/src/ape/pytest/plugin.py b/src/ape/pytest/plugin.py index 2ed9e7befd..c26d010ea8 100644 --- a/src/ape/pytest/plugin.py +++ b/src/ape/pytest/plugin.py @@ -28,7 +28,7 @@ def add_option(*names, **kwargs): raise ConfigError(f"Failed adding option {name_str}: {err}") from err - add_option("--showinternal", action="store_true") + add_option("--show-internal", action="store_true") add_option( "--network", action="store", @@ -63,7 +63,7 @@ def add_option(*names, **kwargs): def pytest_configure(config): # Do not include ape internals in tracebacks unless explicitly asked - if not config.getoption("showinternal"): + if not config.getoption("--show-internal"): path_str = sys.modules["ape"].__file__ if path_str: base_path = Path(path_str).parent.as_posix() diff --git a/src/ape_geth/provider.py b/src/ape_geth/provider.py index 57838f8beb..cf55cbce37 100644 --- a/src/ape_geth/provider.py +++ b/src/ape_geth/provider.py @@ -312,7 +312,7 @@ def disconnect(self): def snapshot(self) -> SnapshotID: return self.get_block("latest").number or 0 - def revert(self, snapshot_id: SnapshotID): + def restore(self, snapshot_id: SnapshotID): if isinstance(snapshot_id, int): block_number_int = snapshot_id block_number_hex_str = str(to_hex(snapshot_id)) diff --git a/src/ape_test/__init__.py b/src/ape_test/__init__.py index f435039935..578c8b80d8 100644 --- a/src/ape_test/__init__.py +++ b/src/ape_test/__init__.py @@ -1,6 +1,6 @@ from typing import Dict, List, NewType, Optional, Union -from pydantic import NonNegativeInt +from pydantic import NonNegativeInt, field_validator from ape import plugins from ape.api import PluginConfig @@ -23,11 +23,6 @@ class GasConfig(PluginConfig): Configuration related to test gas reports. """ - show: bool = False - """ - Set to ``True`` to always show gas. - """ - exclude: List[GasExclusion] = [] """ Contract methods patterns to skip. Specify ``contract_name:`` and not @@ -37,6 +32,27 @@ class GasConfig(PluginConfig): use ``prefix_*`` to skip all items with a certain prefix. """ + reports: List[str] = [] + """ + Report-types to use. Currently, only supports `terminal`. + """ + + @field_validator("reports", mode="before") + @classmethod + def validate_reports(cls, values): + values = list(set(values or [])) + valid = ("terminal",) + for val in values: + if val not in valid: + valid_str = ", ".join(valid) + raise ValueError(f"Invalid gas-report format '{val}'. Valid: {valid_str}") + + return values + + @property + def show(self) -> bool: + return "terminal" in self.reports + """Dict is for extra report settings.""" _ReportType = Union[bool, Dict] diff --git a/src/ape_test/provider.py b/src/ape_test/provider.py index 869295ae62..af5007f4cc 100644 --- a/src/ape_test/provider.py +++ b/src/ape_test/provider.py @@ -234,7 +234,7 @@ def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI: def snapshot(self) -> SnapshotID: return self.evm_backend.take_snapshot() - def revert(self, snapshot_id: SnapshotID): + def restore(self, snapshot_id: SnapshotID): if snapshot_id: current_hash = self.get_block("latest").hash if current_hash != snapshot_id: diff --git a/tests/functional/geth/test_provider.py b/tests/functional/geth/test_provider.py index 16e07b1b2d..818850510f 100644 --- a/tests/functional/geth/test_provider.py +++ b/tests/functional/geth/test_provider.py @@ -219,7 +219,7 @@ def test_snapshot_and_revert(geth_provider, geth_account, geth_contract): assert actual_block_number == expected_block_number assert actual_nonce == expected_nonce - geth_provider.revert(snapshot) + geth_provider.restore(snapshot) actual_block_number = geth_provider.get_block("latest").number expected_block_number = snapshot @@ -277,7 +277,7 @@ def test_get_pending_block(geth_provider, geth_account, geth_second_account, acc assert isinstance(actual, Block) # Restore state before transaction - geth_provider.revert(snap) + geth_provider.restore(snap) actual = geth_provider.get_block("latest") assert isinstance(actual, Block) diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py index 627a018f06..fd71708161 100644 --- a/tests/functional/test_cli.py +++ b/tests/functional/test_cli.py @@ -7,7 +7,6 @@ from ape.cli import ( AccountAliasPromptChoice, ConnectedProviderCommand, - NetworkBoundCommand, NetworkChoice, PromptChoice, account_option, @@ -600,28 +599,6 @@ def cmd(network, provider): assert res.exit_code == 0, res.output -# TODO: Delete for 0.8. -def test_deprecated_network_bound_command(runner): - with pytest.warns( - DeprecationWarning, - match=r"'NetworkBoundCommand' is deprecated\. Use 'ConnectedProviderCommand'\.", - ): - - @click.command(cls=NetworkBoundCommand) - @network_option() - # NOTE: Must also make sure can use other options with this combo! - # (was issue where could not). - @click.option("--other", default=OTHER_OPTION_VALUE) - def cmd(network, other): - click.echo(network) - click.echo(other) - - result = runner.invoke(cmd, ["--network", "ethereum:local:test"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert "ethereum:local:test" in result.output, result.output - assert OTHER_OPTION_VALUE in result.output - - def test_get_param_from_ctx(mocker): mock_ctx = mocker.MagicMock() mock_ctx.params = {"foo": "bar"} diff --git a/tests/integration/cli/test_test.py b/tests/integration/cli/test_test.py index 9f7b784bce..753f7ca29b 100644 --- a/tests/integration/cli/test_test.py +++ b/tests/integration/cli/test_test.py @@ -172,7 +172,7 @@ def test_uncaught_txn_err(setup_pytester, project, pytester, eth_tester_provider def test_show_internal(setup_pytester, project, pytester, eth_tester_provider): _ = eth_tester_provider # Ensure using EthTester for this test. setup_pytester(project.path.name) - result = pytester.runpytest("--showinternal") + result = pytester.runpytest("--show-internal") expected = """ raise vm_err from err E ape.exceptions.ContractLogicError: Transaction failed. @@ -244,7 +244,8 @@ def test_gas_flag_set_in_config( test: disconnect_providers_after: false gas: - show: true + reports: + - terminal """ with switch_config(project, config_content): @@ -275,7 +276,8 @@ def test_gas_when_estimating( test: disconnect_providers_after: false gas: - show: true + reports: + - terminal """ geth_account.transfer(geth_account, "1 wei") # Force a clean block. @@ -363,7 +365,7 @@ def test_coverage(geth_provider, setup_pytester, project, pytester, geth_account """ geth_account.transfer(geth_account, "1 wei") # Force a clean block. passed, failed = setup_pytester(project.path.name) - result = pytester.runpytest("--coverage", "--showinternal", "--network", "ethereum:local:geth") + result = pytester.runpytest("--coverage", "--show-internal", "--network", "ethereum:local:geth") result.assert_outcomes(passed=passed, failed=failed) From 66c035bbff45396011319e93a88fb7b3bc9d9192 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 15:19:56 -0600 Subject: [PATCH 22/24] refactor: config for nets (#1981) --- src/ape/api/networks.py | 20 ++++++++++---------- src/ape_ethereum/ecosystem.py | 4 ++-- src/ape_ethereum/provider.py | 2 +- tests/functional/test_network_api.py | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index 03a9b1c6a3..c7b80feeaa 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -795,7 +795,7 @@ def __repr__(self) -> str: return f"<{name}>" if name else f"{type(self)}" @property - def config(self) -> PluginConfig: + def ecosystem_config(self) -> PluginConfig: """ The configuration of the network. See :class:`~ape.managers.config.ConfigManager` for more information on plugin configurations. @@ -804,11 +804,11 @@ def config(self) -> PluginConfig: return self.config_manager.get_config(self.ecosystem.name) @property - def _network_config(self) -> PluginConfig: + def config(self) -> PluginConfig: name_options = {self.name, self.name.replace("-", "_"), self.name.replace("_", "-")} cfg: Any for opt in name_options: - if cfg := self.config.get(opt): + if cfg := self.ecosystem_config.get(opt): if isinstance(cfg, dict): return cfg @@ -824,7 +824,7 @@ def _network_config(self) -> PluginConfig: @cached_property def gas_limit(self) -> GasLimit: - return self._network_config.get("gas_limit", "auto") + return self.config.get("gas_limit", "auto") @cached_property def auto_gas_multiplier(self) -> float: @@ -838,7 +838,7 @@ def base_fee_multiplier(self) -> float: """ A multiplier to apply to a transaction base fee. """ - return self._network_config.get("base_fee_multiplier", 1.0) + return self.config.get("base_fee_multiplier", 1.0) @property def chain_id(self) -> int: @@ -869,7 +869,7 @@ def required_confirmations(self) -> int: refer to the number of blocks that have been added since the transaction's block. """ - return self._network_config.get("required_confirmations", 0) + return self.config.get("required_confirmations", 0) @property def block_time(self) -> int: @@ -884,7 +884,7 @@ def block_time(self) -> int: block_time: 15 """ - return self._network_config.get("block_time", 0) + return self.config.get("block_time", 0) @property def transaction_acceptance_timeout(self) -> int: @@ -893,7 +893,7 @@ def transaction_acceptance_timeout(self) -> int: Does not include waiting for block-confirmations. Defaults to two minutes. Local networks use smaller timeouts. """ - return self._network_config.get( + return self.config.get( "transaction_acceptance_timeout", DEFAULT_TRANSACTION_ACCEPTANCE_TIMEOUT ) @@ -1113,7 +1113,7 @@ def default_provider_name(self) -> Optional[str]: # Was set programatically. return provider - elif provider_from_config := self._network_config.get("default_provider"): + elif provider_from_config := self.config.get("default_provider"): # The default is found in the Network's config class. return provider_from_config @@ -1239,7 +1239,7 @@ def upstream_provider(self) -> "UpstreamProvider": exists. """ - config_choice: str = self._network_config.get("upstream_provider") + config_choice: str = self.config.get("upstream_provider") if provider_name := config_choice or self.upstream_network.default_provider_name: return self.upstream_network.get_provider(provider_name) diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 8725bcbbf8..083ebf5c93 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -356,8 +356,8 @@ def default_transaction_type(self) -> TransactionType: for name in networks_to_check: network = self.get_network(name) - ecosystem_default = network.config.DEFAULT_TRANSACTION_TYPE - result: int = network._network_config.get("default_transaction_type", ecosystem_default) + ecosystem_default = network.ecosystem_config.DEFAULT_TRANSACTION_TYPE + result: int = network.config.get("default_transaction_type", ecosystem_default) return TransactionType(result) return TransactionType(DEFAULT_TRANSACTION_TYPE) diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index 286598b055..78d48d884a 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -552,7 +552,7 @@ def get_receipt( except TimeExhausted as err: raise TransactionNotFoundError(txn_hash, error_messsage=str(err)) from err - ecosystem_config = self.network.config.model_dump(by_alias=True, mode="json") + ecosystem_config = self.network.ecosystem_config.model_dump(by_alias=True, mode="json") network_config: Dict = ecosystem_config.get(self.network.name, {}) max_retries = network_config.get("max_get_transaction_retries", DEFAULT_MAX_RETRIES_TX) txn = {} diff --git a/tests/functional/test_network_api.py b/tests/functional/test_network_api.py index 850ae9dc3d..37deaab08d 100644 --- a/tests/functional/test_network_api.py +++ b/tests/functional/test_network_api.py @@ -72,7 +72,7 @@ def test_forked_networks(ethereum): # Just make sure it doesn't fail when trying to access. assert mainnet_fork.upstream_provider # Ensure has default configurations. - cfg = mainnet_fork.config.mainnet_fork + cfg = mainnet_fork.ecosystem_config.mainnet_fork assert cfg.default_transaction_type == TransactionType.DYNAMIC assert cfg.block_time == 0 assert cfg.default_provider is None @@ -86,7 +86,7 @@ def test_forked_network_with_config(temp_config, ethereum): "ethereum": {"mainnet_fork": {"default_transaction_type": TransactionType.STATIC.value}} } with temp_config(data): - cfg = ethereum.mainnet_fork.config.mainnet_fork + cfg = ethereum.mainnet_fork.ecosystem_config.mainnet_fork assert cfg.default_transaction_type == TransactionType.STATIC assert cfg.block_time == 0 assert cfg.default_provider is None @@ -108,7 +108,7 @@ def test_config_custom_networks_default(ethereum, custom_networks_config): present. """ network = ethereum.apenet - cfg = network.config.apenet + cfg = network.ecosystem_config.apenet assert cfg.default_transaction_type == TransactionType.DYNAMIC @@ -121,7 +121,7 @@ def test_config_custom_networks( } with temp_config(data): network = ethereum.apenet - ethereum_config = network.config + ethereum_config = network.ecosystem_config cfg_by_attr = ethereum_config.apenet assert cfg_by_attr.default_transaction_type == TransactionType.STATIC @@ -143,7 +143,7 @@ def test_config_networks_from_custom_ecosystem( with temp_config(data): custom_ecosystem = networks.get_ecosystem("custom-ecosystem") network = custom_ecosystem.get_network("apenet") - ethereum_config = network.config + ethereum_config = network.ecosystem_config cfg_by_attr = ethereum_config.apenet assert cfg_by_attr.default_transaction_type == TransactionType.STATIC From 3f5c6a231802e4746eff5987f72747f1a0e705a2 Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 16 Apr 2024 15:28:00 -0600 Subject: [PATCH 23/24] feat: gh swap (#1915) --- setup.py | 1 - src/ape/managers/project/dependency.py | 22 ++- src/ape/plugins/_utils.py | 3 +- src/ape/utils/__init__.py | 3 - src/ape/utils/_github.py | 219 ++++++++++++++++++++++++ src/ape/utils/github.py | 226 ------------------------- src/ape_init/_cli.py | 2 +- src/ape_plugins/_cli.py | 2 +- tests/functional/utils/test_github.py | 100 +++++------ 9 files changed, 282 insertions(+), 296 deletions(-) create mode 100644 src/ape/utils/_github.py delete mode 100644 src/ape/utils/github.py diff --git a/setup.py b/setup.py index eab553124b..986fd04a25 100644 --- a/setup.py +++ b/setup.py @@ -106,7 +106,6 @@ "pluggy>=1.3,<2", "pydantic>=2.5.2,<3", "pydantic-settings>=2.0.3,<3", - "PyGithub>=1.59,<2", "pytest>=6.0,<8.0", "python-dateutil>=2.8.2,<3", "PyYAML>=5.0,<7", diff --git a/src/ape/managers/project/dependency.py b/src/ape/managers/project/dependency.py index 32512e1879..c4734325bf 100644 --- a/src/ape/managers/project/dependency.py +++ b/src/ape/managers/project/dependency.py @@ -15,11 +15,11 @@ from ape.utils import ( ManagerAccessMixin, cached_property, - github_client, load_config, log_instead_of_fail, pragma_str_to_specifier_set, ) +from ape.utils._github import github_client class DependencyManager(ManagerAccessMixin): @@ -221,8 +221,16 @@ def version_id(self) -> str: elif self.version and self.version != "latest": return self.version - latest_release = github_client.get_release(self.github, "latest") - return latest_release.tag_name + latest_release = github_client.get_latest_release(self.org_name, self.repo_name) + return latest_release["tag_name"] + + @cached_property + def org_name(self) -> str: + return self.github.split("/")[0] + + @cached_property + def repo_name(self) -> str: + return self.github.split("/")[1] @property def uri(self) -> AnyUrl: @@ -250,12 +258,14 @@ def extract_manifest(self, use_cache: bool = True) -> PackageManifest: temp_project_path.mkdir(exist_ok=True, parents=True) if self.ref: - github_client.clone_repo(self.github, temp_project_path, branch=self.ref) + github_client.clone_repo( + self.org_name, self.repo_name, temp_project_path, branch=self.ref + ) else: try: github_client.download_package( - self.github, self.version or "latest", temp_project_path + self.org_name, self.repo_name, self.version or "latest", temp_project_path ) except UnknownVersionError as err: logger.warning( @@ -265,7 +275,7 @@ def extract_manifest(self, use_cache: bool = True) -> PackageManifest: ) try: github_client.clone_repo( - self.github, temp_project_path, branch=self.version + self.org_name, self.repo_name, temp_project_path, branch=self.version ) except Exception: # Raise the UnknownVersionError. diff --git a/src/ape/plugins/_utils.py b/src/ape/plugins/_utils.py index 3c42581e11..c02cbd3142 100644 --- a/src/ape/plugins/_utils.py +++ b/src/ape/plugins/_utils.py @@ -11,7 +11,8 @@ from ape.__modules__ import __modules__ from ape.logging import logger from ape.plugins import clean_plugin_name -from ape.utils import BaseInterfaceModel, get_package_version, github_client, log_instead_of_fail +from ape.utils import BaseInterfaceModel, get_package_version, log_instead_of_fail +from ape.utils._github import github_client from ape.utils.basemodel import BaseModel from ape.utils.misc import _get_distributions from ape.version import version as ape_version_str diff --git a/src/ape/utils/__init__.py b/src/ape/utils/__init__.py index f8782b556a..be86215134 100644 --- a/src/ape/utils/__init__.py +++ b/src/ape/utils/__init__.py @@ -18,7 +18,6 @@ ManagerAccessMixin, injected_before_use, ) -from ape.utils.github import GithubClient, github_client from ape.utils.misc import ( DEFAULT_LIVE_NETWORK_BASE_FEE_MULTIPLIER, DEFAULT_LOCAL_TRANSACTION_ACCEPTANCE_TIMEOUT, @@ -84,8 +83,6 @@ "get_relative_path", "gas_estimation_error_message", "get_package_version", - "GithubClient", - "github_client", "GeneratedDevAccount", "generate_dev_accounts", "get_all_files_in_directory", diff --git a/src/ape/utils/_github.py b/src/ape/utils/_github.py new file mode 100644 index 0000000000..f5252794c7 --- /dev/null +++ b/src/ape/utils/_github.py @@ -0,0 +1,219 @@ +import os +import shutil +import subprocess +import tempfile +import zipfile +from io import BytesIO +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Union + +from requests import Session +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from ape.exceptions import CompilerError, ProjectError, UnknownVersionError +from ape.logging import logger +from ape.utils.misc import USER_AGENT, cached_property, stream_response + + +class GitProcessWrapper: + @cached_property + def git(self) -> str: + if path := shutil.which("git"): + return path + + raise ProjectError("`git` not installed.") + + def clone(self, url: str, target_path: Optional[Path] = None, branch: Optional[str] = None): + command = [self.git, "-c", "advice.detachedHead=false", "clone", url] + + if target_path: + command.append(str(target_path)) + + if branch is not None: + command.extend(("--branch", branch)) + + logger.debug(f"Running git command: '{' '.join(command)}'") + result = subprocess.call(command) + if result != 0: + fail_msg = f"`git clone` command failed for '{url}'." + + if branch and not branch.startswith("v"): + # Often times, `v` is required for tags. + try: + self.clone(url, target_path, branch=f"v{branch}") + except Exception: + raise ProjectError(fail_msg) + + # Succeeded when prefixing `v`. + return + + # Failed and we don't really know why. + # Shouldn't really happen. + # User will have to run command separately to debug. + raise ProjectError(fail_msg) + + +# NOTE: This client is only meant to be used internally for ApeWorX projects. +class _GithubClient: + # Generic git/github client attributes. + TOKEN_KEY = "GITHUB_ACCESS_TOKEN" + API_URL_PREFIX = "https://api.github.com" + git: GitProcessWrapper = GitProcessWrapper() + + # ApeWorX-specific attributes. + ORGANIZATION_NAME = "ApeWorX" + FRAMEWORK_NAME = "ape" + _repo_cache: Dict[str, Dict] = {} + + def __init__(self, session: Optional[Session] = None): + if session: + # NOTE: Mostly allowed for testing purposes. + self.__session = session + + else: + headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} + if auth := os.environ[self.TOKEN_KEY] if self.TOKEN_KEY in os.environ else None: + headers["Authorization"] = f"token {auth}" + + session = Session() + session.headers = {**session.headers, **headers} + adapter = HTTPAdapter( + max_retries=Retry(total=10, backoff_factor=1.0, status_forcelist=[403]), + ) + session.mount("https://", adapter) + self.__session = session + + @cached_property + def org(self) -> Dict: + """ + Our organization on ``Github``. + """ + return self.get_organization(self.ORGANIZATION_NAME) + + @cached_property + def available_plugins(self) -> Set[str]: + return { + repo["name"].replace("-", "_") + for repo in self.get_org_repos() + if not repo.get("private", False) and repo["name"].startswith(f"{self.FRAMEWORK_NAME}-") + } + + def get_org_repos(self) -> List[Dict]: + return self._get(f"orgs/{self.ORGANIZATION_NAME}/repos") + + def get_release(self, org_name: str, repo_name: str, version: str) -> Dict: + if version == "latest": + return self.get_latest_release(org_name, repo_name) + + def _try_get_release(vers): + try: + return self._get_release(org_name, repo_name, vers) + except Exception: + return None + + if release := _try_get_release(version): + return release + else: + original_version = str(version) + # Try an alternative tag style + if version.startswith("v"): + version = version.lstrip("v") + else: + version = f"v{version}" + + if release := _try_get_release(version): + return release + + raise UnknownVersionError(original_version, repo_name) + + def _get_release(self, org_name: str, repo_name: str, version: str) -> Dict: + return self._get(f"repos/{org_name}/{repo_name}/releases/tags/{version}") + + def get_repo(self, org_name: str, repo_name: str) -> Dict: + repo_path = f"{org_name}/{repo_name}" + if repo_path not in self._repo_cache: + try: + self._repo_cache[repo_path] = self._get_repo(org_name, repo_name) + return self._repo_cache[repo_path] + except Exception as err: + raise ProjectError(f"Unknown repository '{repo_path}'") from err + + else: + return self._repo_cache[repo_path] + + def _get_repo(self, org_name: str, repo_name: str) -> Dict: + return self._get(f"repos/{org_name}/{repo_name}") + + def get_latest_release(self, org_name: str, repo_name: str) -> Dict: + return self._get(f"repos/{org_name}/{repo_name}/releases/latest") + + def get_organization(self, org_name: str) -> Dict: + return self._get(f"orgs/{org_name}") + + def clone_repo( + self, + org_name: str, + repo_name: str, + target_path: Union[str, Path], + branch: Optional[str] = None, + scheme: str = "http", + ): + repo = self.get_repo(org_name, repo_name) + branch = branch or repo["default_branch"] + logger.info(f"Cloning branch '{branch}' from '{repo['name']}'.") + url = repo["git_url"] + + if "ssh" in scheme or "git" in scheme: + url = url.replace("git://github.com/", "git@github.com:") + elif "http" in scheme: + url = url.replace("git://", "https://") + else: + raise ValueError(f"Scheme '{scheme}' not supported.") + + target_path = Path(target_path) + if target_path.exists(): + # Else, cloning will fail! + target_path = target_path / repo_name + + self.git.clone(url, branch=branch, target_path=target_path) + + def download_package( + self, org_name: str, repo_name: str, version: str, target_path: Union[Path, str] + ): + target_path = Path(target_path) # Handles str + if not target_path or not target_path.is_dir(): + raise ValueError(f"'target_path' must be a valid directory (got '{target_path}').") + + release = self.get_release(org_name, repo_name, version) + description = f"Downloading {org_name}/{repo_name}@{version}" + release_content = stream_response( + release["zipball_url"], progress_bar_description=description + ) + + # Use temporary path to isolate a package when unzipping + with tempfile.TemporaryDirectory() as tmp: + temp_path = Path(tmp) + with zipfile.ZipFile(BytesIO(release_content)) as zf: + zf.extractall(temp_path) + + # Copy the directory contents into the target path. + downloaded_packages = [f for f in temp_path.iterdir() if f.is_dir()] + if len(downloaded_packages) < 1: + raise CompilerError(f"Unable to download package at '{org_name}/{repo_name}'.") + + package_path = temp_path / downloaded_packages[0] + for source_file in package_path.iterdir(): + shutil.move(str(source_file), str(target_path)) + + def _get(self, url: str) -> Any: + return self._request("GET", url) + + def _request(self, method: str, url: str, **kwargs) -> Any: + url = f"{self.API_URL_PREFIX}/{url}" + response = self.__session.request(method, url, **kwargs) + response.raise_for_status() + return response.json() + + +github_client = _GithubClient() diff --git a/src/ape/utils/github.py b/src/ape/utils/github.py deleted file mode 100644 index bee3de0567..0000000000 --- a/src/ape/utils/github.py +++ /dev/null @@ -1,226 +0,0 @@ -import os -import shutil -import subprocess -import tempfile -import zipfile -from io import BytesIO -from pathlib import Path -from typing import Dict, Optional, Set - -from github import Github, UnknownObjectException -from github.Auth import Token as GithubToken -from github.GitRelease import GitRelease -from github.Organization import Organization -from github.Repository import Repository as GithubRepository -from urllib3.util.retry import Retry - -from ape.exceptions import CompilerError, ProjectError, UnknownVersionError -from ape.logging import logger -from ape.utils.misc import USER_AGENT, cached_property, stream_response - - -class GitProcessWrapper: - @cached_property - def git(self) -> str: - if path := shutil.which("git"): - return path - - raise ProjectError("`git` not installed.") - - def clone(self, url: str, target_path: Optional[Path] = None, branch: Optional[str] = None): - command = [self.git, "-c", "advice.detachedHead=false", "clone", url] - - if target_path: - command.append(str(target_path)) - - if branch is not None: - command.extend(("--branch", branch)) - - logger.debug(f"Running git command: '{' '.join(command)}'") - result = subprocess.call(command) - if result != 0: - fail_msg = f"`git clone` command failed for '{url}'." - - if branch and not branch.startswith("v"): - # Often times, `v` is required for tags. - try: - self.clone(url, target_path, branch=f"v{branch}") - except Exception: - raise ProjectError(fail_msg) - - # Succeeded when prefixing `v`. - return - - # Failed and we don't really know why. - # Shouldn't really happen. - # User will have to run command separately to debug. - raise ProjectError(fail_msg) - - -class GithubClient: - """ - An HTTP client for the Github API. - """ - - TOKEN_KEY = "GITHUB_ACCESS_TOKEN" - _repo_cache: Dict[str, GithubRepository] = {} - git: GitProcessWrapper = GitProcessWrapper() - - def __init__(self): - token = os.environ[self.TOKEN_KEY] if self.TOKEN_KEY in os.environ else None - auth = GithubToken(token) if token else None - retry = Retry(total=10, backoff_factor=1.0, status_forcelist=[403]) - self._client = Github(auth=auth, user_agent=USER_AGENT, retry=retry) - - @cached_property - def ape_org(self) -> Organization: - """ - The ``ApeWorX`` organization on ``Github`` (https://github.com/ApeWorX). - """ - return self.get_organization("ApeWorX") - - @cached_property - def available_plugins(self) -> Set[str]: - """ - The available ``ape`` plugins, found from looking at the ``ApeWorX`` Github organization. - - Returns: - Set[str]: The plugin names as ``'ape_plugin_name'`` (module-like). - """ - return { - repo.name.replace("-", "_") - for repo in self.ape_org.get_repos() - if not repo.private and repo.name.startswith("ape-") - } - - def get_release(self, repo_path: str, version: str) -> GitRelease: - """ - Get a release from Github. - - Args: - repo_path (str): The path on Github to the repository, - e.g. ``OpenZeppelin/openzeppelin-contracts``. - version (str): The version of the release to get. Pass in ``"latest"`` - to get the latest release. - - Returns: - github.GitRelease.GitRelease - """ - repo = self.get_repo(repo_path) - - if version == "latest": - return repo.get_latest_release() - - def _try_get_release(vers): - try: - return repo.get_release(vers) - except UnknownObjectException: - return None - - if release := _try_get_release(version): - return release - else: - original_version = str(version) - # Try an alternative tag style - if version.startswith("v"): - version = version.lstrip("v") - else: - version = f"v{version}" - - if release := _try_get_release(version): - return release - - raise UnknownVersionError(original_version, repo.name) - - def get_repo(self, repo_path: str) -> GithubRepository: - """ - Get a repository from GitHub. - - Args: - repo_path (str): The path to the repository, such as - ``OpenZeppelin/openzeppelin-contracts``. - - Returns: - github.Repository.Repository - """ - - if repo_path not in self._repo_cache: - try: - self._repo_cache[repo_path] = self._client.get_repo(repo_path) - return self._repo_cache[repo_path] - except UnknownObjectException as err: - raise ProjectError(f"Unknown repository '{repo_path}'") from err - - else: - return self._repo_cache[repo_path] - - def get_organization(self, name: str) -> Organization: - return self._client.get_organization(name) - - def clone_repo( - self, - repo_path: str, - target_path: Path, - branch: Optional[str] = None, - scheme: str = "http", - ): - """ - Clone a repository from Github. - - Args: - repo_path (str): The path on Github to the repository, - e.g. ``OpenZeppelin/openzeppelin-contracts``. - target_path (Path): The local path to store the repo. - branch (Optional[str]): The branch to clone. Defaults to the default branch. - scheme (str): The git scheme to use when cloning. Defaults to `ssh`. - """ - - repo = self.get_repo(repo_path) - branch = branch or repo.default_branch - logger.info(f"Cloning branch '{branch}' from '{repo.name}'.") - url = repo.git_url - - if "ssh" in scheme or "git" in scheme: - url = url.replace("git://github.com/", "git@github.com:") - elif "http" in scheme: - url = url.replace("git://", "https://") - else: - raise ValueError(f"Scheme '{scheme}' not supported.") - - self.git.clone(url, branch=branch, target_path=target_path) - - def download_package(self, repo_path: str, version: str, target_path: Path): - """ - Download a package from Github. This is useful for managing project dependencies. - - Args: - repo_path (str): The path on ``Github`` to the repository, - such as ``OpenZeppelin/openzeppelin-contracts``. - version (str): Number to specify update types - to the downloaded package. - target_path (path): A path in your local filesystem to save the downloaded package. - """ - if not target_path or not target_path.is_dir(): - raise ValueError(f"'target_path' must be a valid directory (got '{target_path}').") - - release = self.get_release(repo_path, version) - description = f"Downloading {repo_path}@{version}" - release_content = stream_response(release.zipball_url, progress_bar_description=description) - - # Use temporary path to isolate a package when unzipping - with tempfile.TemporaryDirectory() as tmp: - temp_path = Path(tmp) - with zipfile.ZipFile(BytesIO(release_content)) as zf: - zf.extractall(temp_path) - - # Copy the directory contents into the target path. - downloaded_packages = [f for f in temp_path.iterdir() if f.is_dir()] - if len(downloaded_packages) < 1: - raise CompilerError(f"Unable to download package at '{repo_path}'.") - - package_path = temp_path / downloaded_packages[0] - for source_file in package_path.iterdir(): - shutil.move(str(source_file), str(target_path)) - - -github_client = GithubClient() diff --git a/src/ape_init/_cli.py b/src/ape_init/_cli.py index 788b7ebd99..dd68d935b5 100644 --- a/src/ape_init/_cli.py +++ b/src/ape_init/_cli.py @@ -5,7 +5,7 @@ from ape.cli import ape_cli_context from ape.managers.config import CONFIG_FILE_NAME -from ape.utils import github_client +from ape.utils._github import github_client GITIGNORE_CONTENT = """ # Ape stuff diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index 3a28d4ff3b..22d211c301 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -16,7 +16,7 @@ PluginType, ape_version, ) -from ape.utils import load_config +from ape.utils.misc import load_config from ape.utils.misc import _get_distributions diff --git a/tests/functional/utils/test_github.py b/tests/functional/utils/test_github.py index eaecc0db0a..681dbac322 100644 --- a/tests/functional/utils/test_github.py +++ b/tests/functional/utils/test_github.py @@ -2,19 +2,20 @@ from pathlib import Path import pytest -from github import UnknownObjectException from requests.exceptions import ConnectTimeout -from ape.utils.github import GithubClient +from ape.utils._github import _GithubClient -REPO_PATH = "test/path" +ORG_NAME = "test" +REPO_NAME = "path" +REPO_PATH = f"{ORG_NAME}/{REPO_NAME}" @pytest.fixture(autouse=True) -def clear_repo_cache(github_client_with_mocks): +def clear_repo_cache(github_client): def clear(): - if REPO_PATH in github_client_with_mocks._repo_cache: - del github_client_with_mocks._repo_cache[REPO_PATH] + if REPO_PATH in github_client._repo_cache: + del github_client._repo_cache[REPO_PATH] clear() yield @@ -22,37 +23,31 @@ def clear(): @pytest.fixture -def mock_client(mocker): - return mocker.MagicMock() - - -@pytest.fixture -def mock_repo(mocker): +def mock_session(mocker): return mocker.MagicMock() @pytest.fixture def mock_release(mocker): - return mocker.MagicMock() + release = mocker.MagicMock() + release.json.return_value = {"name": REPO_NAME} + return release @pytest.fixture -def github_client_with_mocks(mock_client, mock_repo): - client = GithubClient() - mock_client.get_repo.return_value = mock_repo - client._client = mock_client - return client +def github_client(mock_session): + return _GithubClient(session=mock_session) class TestGithubClient: def test_clone_repo(self, mocker): # NOTE: this test actually clones the repo. - client = GithubClient() - git_patch = mocker.patch("ape.utils.github.subprocess.call") + client = _GithubClient() + git_patch = mocker.patch("ape.utils._github.subprocess.call") git_patch.return_value = 0 with tempfile.TemporaryDirectory() as temp_dir: try: - client.clone_repo("dapphub/ds-test", Path(temp_dir), branch="master") + client.clone_repo("dapphub", "ds-test", Path(temp_dir), branch="master") except ConnectTimeout: pytest.xfail("Internet required to run this test.") @@ -66,42 +61,33 @@ def test_clone_repo(self, mocker): assert cmd[6] == "--branch" assert cmd[7] == "master" - def test_get_release(self, github_client_with_mocks, mock_repo): - github_client_with_mocks.get_release(REPO_PATH, "0.1.0") - - # Test that we used the given tag. - mock_repo.get_release.assert_called_once_with("0.1.0") - - # Ensure that it uses the repo cache the second time - github_client_with_mocks.get_release(REPO_PATH, "0.1.0") - assert github_client_with_mocks._client.get_repo.call_count == 1 - - def test_get_release_when_tag_fails_tries_with_v( - self, mock_release, github_client_with_mocks, mock_repo - ): - # This test makes sure that if we try to get a release and the `v` is not needed, - # it will try again without the `v`. - def side_effect(version): - if version.startswith("v"): - raise UnknownObjectException(400, {}, {}) - - return mock_release - - mock_repo.get_release.side_effect = side_effect - actual = github_client_with_mocks.get_release(REPO_PATH, "v0.1.0") - assert actual == mock_release - - def test_get_release_when_tag_fails_tries_without_v( - self, mock_release, github_client_with_mocks, mock_repo - ): - # This test makes sure that if we try to get a release and the `v` is needed, - # it will try again with the `v`. - def side_effect(version): - if not version.startswith("v"): - raise UnknownObjectException(400, {}, {}) + def test_get_release(self, github_client, mock_session): + version = "0.1.0" + github_client.get_release(ORG_NAME, REPO_NAME, "0.1.0") + base_uri = f"https://api.github.com/repos/{ORG_NAME}/{REPO_NAME}/releases/tags" + expected_uri = f"{base_uri}/{version}" + assert mock_session.request.call_args[0] == ("GET", expected_uri) + + @pytest.mark.parametrize("version", ("0.1.0", "v0.1.0")) + def test_get_release_retry(self, mock_release, github_client, mock_session, version): + """ + Ensure after failing to get a release, we re-attempt with + out a v-prefix. + """ + opposite = version.lstrip("v") if version.startswith("v") else f"v{version}" + + def side_effect(method, uri, *arg, **kwargs): + _version = uri.split("/")[-1] + if _version == version: + # Force it to try the opposite. + raise ValueError() return mock_release - mock_repo.get_release.side_effect = side_effect - actual = github_client_with_mocks.get_release(REPO_PATH, "0.1.0") - assert actual == mock_release + mock_session.request.side_effect = side_effect + actual = github_client.get_release(ORG_NAME, REPO_NAME, version) + assert actual["name"] == REPO_NAME + calls = mock_session.request.call_args_list[-2:] + expected_uri = "https://api.github.com/repos/test/path/releases/tags" + assert calls[0][0] == ("GET", f"{expected_uri}/{version}") + assert calls[1][0] == ("GET", f"{expected_uri}/{opposite}") From cdcc09b9b7fea4b457a2a1824adb2577d8aeddca Mon Sep 17 00:00:00 2001 From: NotPeopling2day <32708219+NotPeopling2day@users.noreply.github.com> Date: Tue, 16 Apr 2024 17:55:02 -0400 Subject: [PATCH 24/24] fix: isort --- src/ape_plugins/_cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index 22d211c301..596117d70b 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -16,8 +16,7 @@ PluginType, ape_version, ) -from ape.utils.misc import load_config -from ape.utils.misc import _get_distributions +from ape.utils.misc import _get_distributions, load_config @click.group(short_help="Manage ape plugins")