diff --git a/src/ape/api/config.py b/src/ape/api/config.py index c16b0c8890..80e5d3c86c 100644 --- a/src/ape/api/config.py +++ b/src/ape/api/config.py @@ -4,6 +4,8 @@ from pydantic import ConfigDict from pydantic_settings import BaseSettings +from ape.utils.basemodel import _assert_not_ipython_check + ConfigItemType = TypeVar("ConfigItemType") @@ -49,6 +51,8 @@ def update(root: Dict, value_map: Dict): return cls(**update(default_values, overrides)) def __getattr__(self, attr_name: str) -> Any: + _assert_not_ipython_check(attr_name) + # Allow hyphens in plugin config files. attr_name = attr_name.replace("-", "_") extra = self.__pydantic_extra__ or {} diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index 3eb11252b4..8554c3d1d9 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -290,7 +290,7 @@ def __post_init__(self): def __ape_extra_attributes__(self) -> Iterator[ExtraModelAttributes]: yield ExtraModelAttributes( name="networks", - attributes=self.networks, + attributes=lambda: self.networks, include_getattr=True, include_getitem=True, ) diff --git a/src/ape/api/projects.py b/src/ape/api/projects.py index 55a36a8c4a..2fe6b21aaf 100644 --- a/src/ape/api/projects.py +++ b/src/ape/api/projects.py @@ -366,7 +366,7 @@ def __repr__(self): def __ape_extra_attributes__(self) -> Iterator[ExtraModelAttributes]: yield ExtraModelAttributes( name=self.name, - attributes=self.contracts, + attributes=lambda: self.contracts, include_getattr=True, include_getitem=True, additional_error_message="Do you have the necessary compiler plugins installed?", diff --git a/src/ape/api/transactions.py b/src/ape/api/transactions.py index 4aa77709b8..716bf7f8c7 100644 --- a/src/ape/api/transactions.py +++ b/src/ape/api/transactions.py @@ -275,7 +275,7 @@ def __repr__(self) -> str: return f"<{cls_name} {self.txn_hash}>" def __ape_extra_attributes__(self) -> Iterator[ExtraModelAttributes]: - yield ExtraModelAttributes(name="transaction", attributes=vars(self.transaction)) + yield ExtraModelAttributes(name="transaction", attributes=lambda: vars(self.transaction)) @field_validator("transaction", mode="before") @classmethod diff --git a/src/ape/contracts/base.py b/src/ape/contracts/base.py index f4d3baca92..face4bec4c 100644 --- a/src/ape/contracts/base.py +++ b/src/ape/contracts/base.py @@ -30,6 +30,7 @@ from ape.types import AddressType, ContractLog, LogFilter, MockContractLog from ape.utils import BaseInterfaceModel, ManagerAccessMixin, cached_property, singledispatchmethod from ape.utils.abi import StructParser +from ape.utils.basemodel import _assert_not_ipython_check class ContractConstructor(ManagerAccessMixin): @@ -1163,7 +1164,7 @@ def __getattr__(self, attr_name: str) -> Any: Returns: Any: The return value from the contract call, or a transaction receipt. """ - + _assert_not_ipython_check(attr_name) if attr_name in set(super(BaseAddress, self).__dir__()): return super(BaseAddress, self).__getattribute__(attr_name) @@ -1252,7 +1253,7 @@ def __getattr__(self, name: str) -> Any: :class:`~ape.types.ContractEvent` or a subclass of :class:`~ape.exceptions.CustomError` or any real attribute of the class. """ - + _assert_not_ipython_check(name) try: # First, check if requesting a regular attribute on this class. return self.__getattribute__(name) @@ -1464,6 +1465,7 @@ def __getattr__(self, item: str) -> Union[ContractContainer, "ContractNamespace" Union[:class:`~ape.contracts.base.ContractContainer`, :class:`~ape.contracts.base.ContractNamespace`] """ + _assert_not_ipython_check(item) def _get_name(cc: ContractContainer) -> str: return cc.contract_type.name or "" diff --git a/src/ape/managers/base.py b/src/ape/managers/base.py index e53bed9966..d485e6d2b9 100644 --- a/src/ape/managers/base.py +++ b/src/ape/managers/base.py @@ -5,3 +5,11 @@ class BaseManager(ManagerAccessMixin): """ Base manager that allows us to add other IPython integration features """ + + def _repr_mimebundle_(self, include=None, exclude=None): + # This works better than AttributeError for Ape. + raise NotImplementedError("This manager does not implement '_repr_mimebundle_'.") + + def _ipython_display_(self, include=None, exclude=None): + # This works better than AttributeError for Ape. + raise NotImplementedError("This manager does not implement '_ipython_display_'.") diff --git a/src/ape/managers/compilers.py b/src/ape/managers/compilers.py index 091eee371f..20a7cd2581 100644 --- a/src/ape/managers/compilers.py +++ b/src/ape/managers/compilers.py @@ -9,7 +9,8 @@ from ape.exceptions import ApeAttributeError, CompilerError, ContractLogicError from ape.logging import logger from ape.managers.base import BaseManager -from ape.utils import get_relative_path +from ape.utils.basemodel import _assert_not_ipython_check +from ape.utils.os import get_relative_path class CompilerManager(BaseManager): @@ -33,6 +34,8 @@ def __repr__(self): return f"<{cls_name} len(registered_compilers)={num_compilers}>" def __getattr__(self, name: str) -> Any: + _assert_not_ipython_check(name) + try: return self.__getattribute__(name) except AttributeError: diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index 0c5077b33a..eb650b5cb2 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -13,6 +13,7 @@ NetworkNotFoundError, ) from ape.managers.base import BaseManager +from ape.utils.basemodel import _assert_not_ipython_check from ape.utils.misc import _dict_overlay from ape_ethereum.provider import EthereumNodeProvider @@ -303,7 +304,7 @@ def __getattr__(self, attr_name: str) -> EcosystemAPI: eth = networks.ethereum """ - + _assert_not_ipython_check(attr_name) options = {attr_name, attr_name.replace("-", "_"), attr_name.replace("_", "-")} ecosystems = self.ecosystems for opt in options: diff --git a/src/ape/managers/project/manager.py b/src/ape/managers/project/manager.py index f7c9604fd1..aa4f854189 100644 --- a/src/ape/managers/project/manager.py +++ b/src/ape/managers/project/manager.py @@ -16,6 +16,7 @@ from ape.managers.base import BaseManager from ape.managers.project.types import ApeProject, BrownieProject from ape.utils import get_relative_path +from ape.utils.basemodel import _assert_not_ipython_check class ProjectManager(BaseManager): @@ -56,6 +57,15 @@ 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 = "" + + return f"" + @property def dependencies(self) -> Dict[str, Dict[str, DependencyAPI]]: """ @@ -481,7 +491,7 @@ def __getattr__(self, attr_name: str) -> Any: :class:`~ape.contracts.ContractContainer`, a :class:`~ape.contracts.ContractNamespace`, or any attribute. """ - + _assert_not_ipython_check(attr_name) result = self._get_attr(attr_name) if result: return result @@ -526,6 +536,7 @@ def _get_attr(self, attr_name: str) -> Any: try: return self.__getattribute__(attr_name) except AttributeError: + # NOTE: Also handles IPython attributes such as _ipython_display_ if not self._getattr_contracts: # Raise the attribute error as if this method didn't exist. raise diff --git a/src/ape/plugins/__init__.py b/src/ape/plugins/__init__.py index a18f296eca..b6d299bba7 100644 --- a/src/ape/plugins/__init__.py +++ b/src/ape/plugins/__init__.py @@ -7,6 +7,7 @@ from ape.__modules__ import __modules__ from ape.exceptions import ApeAttributeError from ape.logging import logger +from ape.utils.basemodel import _assert_not_ipython_check from .account import AccountPlugin from .compiler import CompilerPlugin @@ -128,6 +129,8 @@ def __repr__(self): return f"<{PluginManager.__name__}>" def __getattr__(self, attr_name: str) -> Iterator[Tuple[str, Tuple]]: + _assert_not_ipython_check(attr_name) + # NOTE: The first time this method is called, the actual # plugin registration occurs. Registration only happens once. self._register_plugins() diff --git a/src/ape/utils/basemodel.py b/src/ape/utils/basemodel.py index c32be09340..ad9227e2de 100644 --- a/src/ape/utils/basemodel.py +++ b/src/ape/utils/basemodel.py @@ -160,6 +160,9 @@ def _get_alt(name: str) -> Optional[str]: return alt +_ATTR_TYPE = Union[Dict[str, Any], RootBaseModel] + + class ExtraModelAttributes(EthpmTypesBaseModel): """ A class for defining extra model attributes. @@ -174,7 +177,7 @@ class ExtraModelAttributes(EthpmTypesBaseModel): we can show a more accurate exception message. """ - attributes: Union[Dict[str, Any], RootBaseModel] + attributes: Union[_ATTR_TYPE, Callable[[], _ATTR_TYPE]] """The attributes.""" include_getattr: bool = True @@ -190,16 +193,12 @@ class ExtraModelAttributes(EthpmTypesBaseModel): """ def __contains__(self, name: str) -> bool: - attr_dict = ( - self.attributes - if isinstance(self.attributes, dict) - else self.attributes.model_dump(by_alias=False) - ) - if name in attr_dict: + attrs = self._attrs() + if name in attrs: return True elif alt := _get_alt(name): - return alt in attr_dict + return alt in attrs return False @@ -226,11 +225,17 @@ def get(self, name: str) -> Optional[Any]: return None def _get(self, name: str) -> Optional[Any]: - return ( - self.attributes.get(name) - if isinstance(self.attributes, dict) - else getattr(self.attributes, name, None) - ) + return self._attrs().get(name) + + def _attrs(self) -> dict: + if isinstance(self.attributes, dict): + return self.attributes + elif isinstance(self.attributes, RootBaseModel): + return self.attributes.model_dump(by_alias=False) + + # Lazy extras. + result = self.attributes() + return result if isinstance(result, dict) else result.model_dump(by_alias=False) class BaseModel(EthpmTypesBaseModel): @@ -256,6 +261,20 @@ def model_copy( return result + def _repr_mimebundle_(self, include=None, exclude=None): + # This works better than AttributeError for Ape. + raise NotImplementedError("This model does not implement '_repr_mimebundle_'.") + + def _ipython_display_(self, include=None, exclude=None): + # This works better than AttributeError for Ape. + raise NotImplementedError("This model does not implement '_ipython_display_'.") + + +def _assert_not_ipython_check(key): + # Perf: IPython expects AttributeError here. + if isinstance(key, str) and key == "_ipython_canary_method_should_not_exist_": + raise AttributeError() + class ExtraAttributesMixin: """ @@ -280,7 +299,7 @@ def __getattr__(self, name: str) -> Any: An overridden ``__getattr__`` implementation that takes into account :meth:`~ape.utils.basemodel.ExtraAttributesMixin.__ape_extra_attributes__`. """ - + _assert_not_ipython_check(name) private_attrs = self.__pydantic_private__ or {} if name in private_attrs: _recursion_checker.reset() diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 753e422245..411b705eba 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -48,6 +48,7 @@ returns_array, to_int, ) +from ape.utils.basemodel import _assert_not_ipython_check from ape.utils.misc import DEFAULT_MAX_RETRIES_TX, DEFAULT_TRANSACTION_TYPE from ape_ethereum.proxies import ( IMPLEMENTATION_ABI, @@ -220,6 +221,7 @@ def local(self) -> NetworkConfig: ) def __getattr__(self, key: str) -> Any: + _assert_not_ipython_check(key) net_key = key.replace("-", "_") if net_key.endswith("_fork"): return self._get_forked_config(net_key) diff --git a/tests/functional/test_project.py b/tests/functional/test_project.py index 7b0c5ce9b3..01d17e31dd 100644 --- a/tests/functional/test_project.py +++ b/tests/functional/test_project.py @@ -443,6 +443,23 @@ def test_getattr_contract_not_exists(project): _ = project.ThisIsNotAContractThatExists +@pytest.mark.parametrize("iypthon_attr_name", ("_repr_mimebundle_", "_ipython_display_")) +def test_getattr_ipython(mocker, project, iypthon_attr_name): + spy = mocker.spy(project, "_get_contract") + getattr(project, iypthon_attr_name) + # Ensure it does not try to do anything with contracts. + assert spy.call_count == 0 + + +def test_getattr_ipython_canary_check(mocker, project): + spy = mocker.spy(project, "_get_contract") + with pytest.raises(AttributeError): + getattr(project, "_ipython_canary_method_should_not_exist_") + + # Ensure it does not try to do anything with contracts. + assert spy.call_count == 0 + + def test_build_file_only_modified_once(project_with_contract): project = project_with_contract artifact = project.path / ".build" / "__local__.json"