Skip to content

Commit 202640b

Browse files
authored
feat: show source code lines / traceback from ReceiptAPI [APE-708] (ApeWorX#1337)
1 parent 7a9d47c commit 202640b

File tree

16 files changed

+743
-74
lines changed

16 files changed

+743
-74
lines changed

codeql-config.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,3 @@ queries:
55

66
paths:
77
- src
8-

setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
],
2020
"lint": [
2121
"black>=23.3.0,<24", # Auto-formatter and linter
22-
"mypy>=0.991", # Static type analyzer
22+
"mypy>=0.991,<1", # Static type analyzer
2323
"types-PyYAML", # Needed due to mypy typeshed
2424
"types-requests", # Needed due to mypy typeshed
2525
"types-setuptools", # Needed due to mypy typeshed
@@ -121,8 +121,8 @@
121121
"web3[tester]>=6.0.0,<7",
122122
# ** Dependencies maintained by ApeWorX **
123123
"eip712>=0.2.1,<0.3",
124-
"ethpm-types>=0.4.5,<0.5",
125-
"evm-trace>=0.1.0a18",
124+
"ethpm-types>=0.5.0,<0.6",
125+
"evm-trace>=0.1.0a19",
126126
],
127127
entry_points={
128128
"console_scripts": ["ape=ape._cli:cli"],

src/ape/api/compiler.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from pathlib import Path
2-
from typing import Dict, List, Optional, Set
2+
from typing import Dict, Iterator, List, Optional, Set, Tuple
33

4-
from ethpm_types import ContractType
4+
from ethpm_types import ContractType, HexBytes
5+
from ethpm_types.source import ContractSource
6+
from evm_trace.geth import TraceFrame as EvmTraceFrame
7+
from evm_trace.geth import create_call_node_data
58
from semantic_version import Version # type: ignore
69

710
from ape.exceptions import ContractLogicError
11+
from ape.types.trace import SourceTraceback, TraceFrame
812
from ape.utils import BaseInterfaceModel, abstractmethod, raises_not_implemented
913

1014

@@ -125,3 +129,35 @@ def enrich_error(self, err: ContractLogicError) -> ContractLogicError:
125129
"""
126130

127131
return err
132+
133+
@raises_not_implemented
134+
def trace_source( # type: ignore[empty-body]
135+
self, contract_type: ContractType, trace: Iterator[TraceFrame], calldata: HexBytes
136+
) -> SourceTraceback:
137+
"""
138+
Get a source-traceback for the given contract type.
139+
The source traceback object contains all the control paths taken in the transaction.
140+
When available, source-code location information is accessible from the object.
141+
142+
Args:
143+
contract_type (``ContractType``): A contract type that was created by this compiler.
144+
trace (Iterator[:class:`~ape.types.trace.TraceFrame`]): The resulting frames from
145+
executing a function defined in the given contract type.
146+
calldata (``HexBytes``): Calldata passed to the top-level call.
147+
148+
Returns:
149+
:class:`~ape.types.trace.SourceTraceback`
150+
"""
151+
152+
def _create_contract_from_call(
153+
self, frame: TraceFrame
154+
) -> Tuple[Optional[ContractSource], HexBytes]:
155+
evm_frame = EvmTraceFrame(**frame.raw)
156+
data = create_call_node_data(evm_frame)
157+
calldata = data["calldata"]
158+
address = self.provider.network.ecosystem.decode_address(data["address"])
159+
if address not in self.chain_manager.contracts:
160+
return None, calldata
161+
162+
called_contract = self.chain_manager.contracts[address]
163+
return self.project_manager._create_contract_source(called_contract), calldata

src/ape/api/projects.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os.path
22
import tempfile
33
from pathlib import Path
4-
from typing import TYPE_CHECKING, Any, Dict, List, Optional
4+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
55

66
import yaml
77
from ethpm_types import Checksum, ContractType, PackageManifest, Source
@@ -171,17 +171,20 @@ def _create_manifest(
171171

172172
@classmethod
173173
def _create_source_dict(
174-
cls, contract_filepaths: List[Path], base_path: Path
174+
cls, contract_filepaths: Union[Path, List[Path]], base_path: Path
175175
) -> Dict[str, Source]:
176+
filepaths = (
177+
[contract_filepaths] if isinstance(contract_filepaths, Path) else contract_filepaths
178+
)
176179
source_imports: Dict[str, List[str]] = cls.compiler_manager.get_imports(
177-
contract_filepaths, base_path
180+
filepaths, base_path
178181
) # {source_id: [import_source_ids, ...], ...}
179182
source_references: Dict[str, List[str]] = cls.compiler_manager.get_references(
180183
imports_dict=source_imports
181184
) # {source_id: [referring_source_ids, ...], ...}
182185

183186
source_dict: Dict[str, Source] = {}
184-
for source_path in contract_filepaths:
187+
for source_path in filepaths:
185188
key = str(get_relative_path(source_path, base_path))
186189
source_dict[key] = Source(
187190
checksum=Checksum(
@@ -354,7 +357,7 @@ def compile(self) -> PackageManifest:
354357
# Create content, including sub-directories.
355358
source_path.parent.mkdir(parents=True, exist_ok=True)
356359
source_path.touch()
357-
source_path.write_text(content)
360+
source_path.write_text(str(content))
358361

359362
# Handle import remapping entries indicated in the manifest file
360363
target_config_file = project.path / project.config_file_name

src/ape/api/transactions.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
TransactionNotFoundError,
2020
)
2121
from ape.logging import logger
22-
from ape.types import AddressType, ContractLogContainer, TraceFrame, TransactionSignature
22+
from ape.types import (
23+
AddressType,
24+
ContractLogContainer,
25+
SourceTraceback,
26+
TraceFrame,
27+
TransactionSignature,
28+
)
2329
from ape.utils import BaseInterfaceModel, abstractmethod, cached_property, raises_not_implemented
2430

2531
if TYPE_CHECKING:
@@ -428,6 +434,15 @@ def return_value(self) -> Any:
428434

429435
return output
430436

437+
@property
438+
@raises_not_implemented
439+
def source_traceback(self) -> SourceTraceback: # type: ignore[empty-body]
440+
"""
441+
A pythonic style traceback for both failing and non-failing receipts.
442+
Requires a provider that implements
443+
:meth:~ape.api.providers.ProviderAPI.get_transaction_trace`.
444+
"""
445+
431446
@raises_not_implemented
432447
def show_trace(self, verbose: bool = False, file: IO[str] = sys.stdout):
433448
"""
@@ -445,6 +460,14 @@ def show_gas_report(self, file: IO[str] = sys.stdout):
445460
Display a gas report for the calls made in this transaction.
446461
"""
447462

463+
@raises_not_implemented
464+
def show_source_traceback(self):
465+
"""
466+
Show a receipt traceback mapping to lines in the source code.
467+
Only works when the contract type and source code are both available,
468+
like in local projects.
469+
"""
470+
448471
def track_gas(self):
449472
"""
450473
Track this receipt's gas in the on-going session gas-report.

src/ape/contracts/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -756,7 +756,7 @@ def receipt(self) -> Optional[ReceiptAPI]:
756756
if not self._cached_receipt and self.txn_hash:
757757
try:
758758
receipt = self.chain_manager.get_receipt(self.txn_hash)
759-
except TransactionNotFoundError:
759+
except (TransactionNotFoundError, ValueError):
760760
return None
761761

762762
self._cached_receipt = receipt

src/ape/exceptions.py

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import sys
2+
import tempfile
23
import time
34
import traceback
45
from collections import deque
56
from functools import cached_property
67
from inspect import getframeinfo, stack
78
from pathlib import Path
9+
from types import CodeType, TracebackType
810
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional
911

1012
import click
@@ -19,7 +21,7 @@
1921
from ape.api.networks import NetworkAPI
2022
from ape.api.providers import SubprocessProvider
2123
from ape.api.transactions import TransactionAPI
22-
from ape.types import AddressType, BlockID, SnapshotID, TraceFrame
24+
from ape.types import AddressType, BlockID, SnapshotID, SourceTraceback, TraceFrame
2325

2426

2527
class ApeException(Exception):
@@ -112,9 +114,24 @@ def __init__(
112114
self.txn = txn
113115
self.trace = trace
114116
self.contract_address = contract_address
117+
self.source_traceback: Optional["SourceTraceback"] = None
115118
ex_message = f"({code}) {message}" if code else message
119+
120+
# Finalizes expected revert message.
116121
super().__init__(ex_message)
117122

123+
if not txn:
124+
return
125+
126+
ape_tb = _get_ape_traceback(self, txn)
127+
if not ape_tb:
128+
return
129+
130+
self.source_traceback = ape_tb
131+
py_tb = _get_custom_python_traceback(self, txn, ape_tb)
132+
if py_tb:
133+
self.__traceback__ = py_tb
134+
118135

119136
class VirtualMachineError(TransactionError):
120137
"""
@@ -532,9 +549,10 @@ def handle_ape_exception(err: ApeException, base_paths: List[Path]) -> bool:
532549
an exception on the exc-stack.
533550
534551
Args:
535-
err (:class:`~ape.exceptions.ApeException`): The transaction error
552+
err (:class:`~ape.exceptions.TransactionError`): The transaction error
536553
being handled.
537-
base_paths (List[Path]): Source base paths for allowed frames.
554+
base_paths (Optional[List[Path]]): Optionally include additional
555+
source-path prefixes to use when finding relevant frames.
538556
539557
Returns:
540558
bool: ``True`` if outputted something.
@@ -621,3 +639,87 @@ def name(self) -> str:
621639
The name of the error.
622640
"""
623641
return self.abi.name
642+
643+
644+
def _get_ape_traceback(err: TransactionError, txn: "TransactionAPI") -> Optional["SourceTraceback"]:
645+
receipt = txn.receipt
646+
if not receipt:
647+
return None
648+
649+
try:
650+
ape_traceback = receipt.source_traceback
651+
except (ApeException, NotImplementedError):
652+
return None
653+
654+
if ape_traceback is None or not len(ape_traceback):
655+
return None
656+
657+
return ape_traceback
658+
659+
660+
def _get_custom_python_traceback(
661+
err: TransactionError, txn: "TransactionAPI", ape_traceback: "SourceTraceback"
662+
) -> Optional[TracebackType]:
663+
# Manipulate python traceback to show lines from contract.
664+
# Help received from Jinja lib:
665+
# https://github.com/pallets/jinja/blob/main/src/jinja2/debug.py#L142
666+
667+
_, exc_value, tb = sys.exc_info()
668+
depth = None
669+
idx = len(ape_traceback) - 1
670+
frames = []
671+
project_path = txn.project_manager.path.as_posix()
672+
while tb is not None:
673+
if not tb.tb_frame.f_code.co_filename.startswith(project_path):
674+
# Ignore frames outside the project.
675+
# This allows both contract code an scripts to appear.
676+
tb = tb.tb_next
677+
continue
678+
679+
frames.append(tb)
680+
tb = tb.tb_next
681+
682+
while (depth is None or depth > 1) and idx >= 0:
683+
exec_item = ape_traceback[idx]
684+
if depth is not None and exec_item.depth >= depth:
685+
# Wait for decreasing depth.
686+
continue
687+
688+
depth = exec_item.depth
689+
lineno = exec_item.begin_lineno
690+
if lineno is None:
691+
continue
692+
693+
if exec_item.source_path is None:
694+
# File is not local. Create a temporary file in its place.
695+
# This is necessary for tracebacks to work in Python.
696+
temp_file = tempfile.NamedTemporaryFile(prefix="unknown_contract_")
697+
filename = temp_file.name
698+
else:
699+
filename = exec_item.source_path.as_posix()
700+
701+
# Raise an exception at the correct line number.
702+
py_code: CodeType = compile(
703+
"\n" * (lineno - 1) + "raise __ape_exception__", filename, "exec"
704+
)
705+
py_code = py_code.replace(co_name=exec_item.closure.name)
706+
707+
# Execute the new code to get a new (fake) tb with contract source info.
708+
try:
709+
exec(py_code, {"__ape_exception__": err}, {})
710+
except BaseException:
711+
fake_tb = sys.exc_info()[2].tb_next # type: ignore
712+
if isinstance(fake_tb, TracebackType):
713+
frames.append(fake_tb)
714+
715+
idx -= 1
716+
717+
if not frames:
718+
return None
719+
720+
tb_next = None
721+
for tb in frames:
722+
tb.tb_next = tb_next
723+
tb_next = tb
724+
725+
return frames[-1]

0 commit comments

Comments
 (0)