Skip to content

Commit

Permalink
fix: issue where Vyper source-lines were not displayed in call-stackt…
Browse files Browse the repository at this point in the history
…races (ApeWorX#2188)
  • Loading branch information
antazoey authored Jul 25, 2024
1 parent abc8a25 commit eb85855
Show file tree
Hide file tree
Showing 3 changed files with 76 additions and 23 deletions.
24 changes: 17 additions & 7 deletions src/ape/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions tests/functional/test_contract_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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")

Expand Down
71 changes: 57 additions & 14 deletions tests/functional/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re
from pathlib import Path

import pytest

from ape.api import ReceiptAPI
from ape.exceptions import (
Abort,
Expand All @@ -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())
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit eb85855

Please sign in to comment.