Skip to content

Commit

Permalink
perf: lazy model extras and IPython canary short-circuitry (#1925)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey authored Feb 9, 2024
1 parent 976f462 commit 8540706
Show file tree
Hide file tree
Showing 13 changed files with 92 additions and 22 deletions.
4 changes: 4 additions & 0 deletions src/ape/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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 {}
Expand Down
2 changes: 1 addition & 1 deletion src/ape/api/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion src/ape/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
2 changes: 1 addition & 1 deletion src/ape/api/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/ape/contracts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ""
Expand Down
8 changes: 8 additions & 0 deletions src/ape/managers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_'.")
5 changes: 4 additions & 1 deletion src/ape/managers/compilers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/ape/managers/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion src/ape/managers/project/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"<ProjectManager{path}>"

@property
def dependencies(self) -> Dict[str, Dict[str, DependencyAPI]]:
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/ape/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
47 changes: 33 additions & 14 deletions src/ape/utils/basemodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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):
Expand All @@ -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:
"""
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/ape_ethereum/ecosystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions tests/functional/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 8540706

Please sign in to comment.