diff --git a/src/ape/exceptions.py b/src/ape/exceptions.py index 088352c041..773d51669e 100644 --- a/src/ape/exceptions.py +++ b/src/ape/exceptions.py @@ -24,6 +24,7 @@ from ape.api.providers import SubprocessProvider from ape.api.trace import TraceAPI from ape.api.transactions import ReceiptAPI, TransactionAPI + from ape.managers.project import ProjectManager from ape.types import AddressType, BlockID, SnapshotID, SourceTraceback @@ -178,6 +179,7 @@ def __init__( trace: Optional["TraceAPI"] = None, contract_address: Optional["AddressType"] = None, source_traceback: Optional["SourceTraceback"] = None, + project: Optional["ProjectManager"] = None, ): message = message or (str(base_err) if base_err else self.DEFAULT_MESSAGE) self.message = message @@ -187,6 +189,7 @@ def __init__( self.trace = trace self.contract_address = contract_address self.source_traceback: Optional["SourceTraceback"] = source_traceback + self._project = project ex_message = f"({code}) {message}" if code else message # Finalizes expected revert message. @@ -224,10 +227,10 @@ def _set_tb(self): if not self.source_traceback and self.txn: self.source_traceback = _get_ape_traceback_from_tx(self.txn) - if (src_tb := self.source_traceback) and self.txn is not None: + if src_tb := self.source_traceback: # Create a custom Pythonic traceback using lines from the sources # found from analyzing the trace of the transaction. - if py_tb := _get_custom_python_traceback(self, self.txn, src_tb): + if py_tb := _get_custom_python_traceback(self, src_tb, project=self._project): self.__traceback__ = py_tb @@ -843,19 +846,27 @@ def _get_ape_traceback_from_tx(txn: FailedTxn) -> Optional["SourceTraceback"]: def _get_custom_python_traceback( - err: TransactionError, txn: FailedTxn, ape_traceback: "SourceTraceback" + err: TransactionError, + ape_traceback: "SourceTraceback", + project: Optional["ProjectManager"] = None, ) -> Optional[TracebackType]: # Manipulate python traceback to show lines from contract. # Help received from Jinja lib: # https://github.com/pallets/jinja/blob/main/src/jinja2/debug.py#L142 + if project is None: + from ape import project + + if not (base_path := getattr(project, "path", None)): + # TODO: Add support for manifest-projects. + return None + _, exc_value, tb = sys.exc_info() depth = None idx = len(ape_traceback) - 1 frames = [] - project_path = txn.local_project.path.as_posix() while tb is not None: - if not tb.tb_frame.f_code.co_filename.startswith(project_path): + if not tb.tb_frame.f_code.co_filename.startswith(str(base_path)): # Ignore frames outside the project. # This allows both contract code an scripts to appear. tb = tb.tb_next @@ -875,7 +886,6 @@ def _get_custom_python_traceback( # NOTE: Use the last lineno executed as "the line number". lineno = exec_item.begin_lineno if exec_item.end_lineno is None else exec_item.end_lineno - if lineno is None: idx -= 1 continue @@ -886,7 +896,7 @@ def _get_custom_python_traceback( temp_file = tempfile.NamedTemporaryFile(prefix="unknown_contract_") filename = temp_file.name else: - filename = exec_item.source_path.as_posix() + filename = str(exec_item.source_path) # Raise an exception at the correct line number. py_code: CodeType = compile( diff --git a/tests/functional/test_contract_instance.py b/tests/functional/test_contract_instance.py index 3451a90d64..eabdf13ac9 100644 --- a/tests/functional/test_contract_instance.py +++ b/tests/functional/test_contract_instance.py @@ -971,7 +971,7 @@ def test_sending_funds_to_non_payable_constructor_by_contractContainerDeploy( ): with pytest.raises( MethodNonPayableError, - match="Sending funds to a non-payable constructor.", + match=r"Sending funds to a non-payable constructor\.", ): solidity_contract_container.deploy(1, sender=owner, value="1 ether") @@ -981,7 +981,7 @@ def test_sending_funds_to_non_payable_constructor_by_accountDeploy( ): with pytest.raises( MethodNonPayableError, - match="Sending funds to a non-payable constructor.", + match=r"Sending funds to a non-payable constructor\.", ): owner.deploy(solidity_contract_container, 1, value="1 ether") diff --git a/tests/functional/test_exceptions.py b/tests/functional/test_exceptions.py index 72aa652531..cf5521be69 100644 --- a/tests/functional/test_exceptions.py +++ b/tests/functional/test_exceptions.py @@ -1,6 +1,8 @@ import re from pathlib import Path +import pytest + from ape.api import ReceiptAPI from ape.exceptions import ( Abort, @@ -12,6 +14,24 @@ from ape_ethereum.transactions import DynamicFeeTransaction, Receipt +@pytest.fixture(scope="module") +def failing_call(): + # A call (tx without sender) + data = { + "chainId": 1337, + "to": "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "gas": 30029122, + "value": 0, + "data": "0xce50aa7d00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000000000000000000001", # noqa: E501 + "type": 2, + "maxFeePerGas": 875000000, + "maxPriorityFeePerGas": 0, + "accessList": [], + } + return DynamicFeeTransaction.model_validate(data) + + class TestAbort: def test_shows_line_number(self): actual = str(Abort()) @@ -61,27 +81,50 @@ def test_deploy_address_as_address( err = TransactionError(txn=tx) assert err.address == contract.address - def test_call(self): + def test_call_with_txn_and_not_source_tb(self, failing_call): """ Simulating a failing-call, making sure it doesn't blow up if it doesn't get a source-tb. """ - data = { - "chainId": 1337, - "to": "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "gas": 30029122, - "value": 0, - "data": "0xce50aa7d00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000000000000000000001", # noqa: E501 - "type": 2, - "maxFeePerGas": 875000000, - "maxPriorityFeePerGas": 0, - "accessList": [], - } - failing_call = DynamicFeeTransaction.model_validate(data) err = TransactionError(txn=failing_call) assert err.source_traceback is None + def test_call_with_source_tb_and_not_txn(self, mocker, project_with_contract): + """ + Simulating a failing call, making sure the source-tb lines + show up when a txn is NOT given. + """ + # Using mocks for simplicity. Otherwise have to use a bunch of models from ethpm-types; + # most of the stuff being mocked seems simple but is calculated from AST-Nodes and such. + src_path = "path/to/VyperFile.vy" + mock_tb = mocker.MagicMock() + mock_exec = mocker.MagicMock() + mock_exec.depth = 1 + mock_exec.source_path = src_path + mock_exec.begin_lineno = 5 + mock_exec.end_lineno = 5 + mock_closure = mocker.MagicMock() + mock_closure.name = "setNumber" + mock_exec.closure = mock_closure + mock_tb.__getitem__.return_value = mock_exec + mock_tb.__len__.return_value = 1 + + err = TransactionError(source_traceback=mock_tb, project=project_with_contract) + + # Have to raise for sys.exc_info() to be available. + try: + raise err + except Exception: + pass + + assert err.__traceback__ is not None + + # The Vyper-frame gets injected at tb_next. + assert err.__traceback__.tb_next is not None + + actual = str(err.__traceback__.tb_next.tb_frame) + assert src_path in actual + class TestNetworkNotFoundError: def test_close_match(self):