diff --git a/brownie/network/account.py b/brownie/network/account.py index 3d5c86792..1bdfa6b6f 100644 --- a/brownie/network/account.py +++ b/brownie/network/account.py @@ -274,6 +274,12 @@ def deploy( ) add_thread = threading.Thread(target=contract._add_from_tx, args=(tx,), daemon=True) add_thread.start() + if rpc.is_active(): + rpc._add_to_undo_buffer( + self.deploy, + (contract, *args), + {"amount": amount, "gas_limit": gas_limit, "gas_price": gas_price}, + ) if tx.status != 1: return tx add_thread.join() @@ -338,6 +344,10 @@ def transfer( revert_data = None except ValueError as e: txid, revert_data = _raise_or_return_tx(e) + + if rpc.is_active(): + rpc._add_to_undo_buffer(self.transfer, (to, amount, gas_limit, gas_price, data), {}) + return TransactionReceipt(txid, self, revert_data=revert_data) diff --git a/brownie/network/contract.py b/brownie/network/contract.py index 7c1d2aa33..20db5a5b7 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -842,14 +842,13 @@ def __call__(self, *args: Tuple) -> Callable: if not CONFIG.argv["always_transact"]: return self.call(*args) - rpc._internal_snap() args, tx = _get_tx(self._owner, args) tx.update({"gas_price": 0, "from": self._owner or accounts[0]}) try: tx = self.transact(*args, tx) return tx.return_value finally: - rpc._internal_revert() + rpc.undo() def _get_tx(owner: Optional[AccountsType], args: Tuple) -> Tuple: diff --git a/brownie/network/rpc.py b/brownie/network/rpc.py index c6c0423c7..0ce3465ee 100644 --- a/brownie/network/rpc.py +++ b/brownie/network/rpc.py @@ -46,8 +46,10 @@ def __init__(self) -> None: self._rpc: Any = None self._time_offset: int = 0 self._snapshot_id: Union[int, Optional[bool]] = False - self._internal_id: Optional[int] = False self._reset_id: Union[int, bool] = False + self._current_id: Union[int, bool] = False + self._undo_buffer: List = [] + self._redo_buffer: List = [] atexit.register(self._at_exit) def _at_exit(self) -> None: @@ -92,7 +94,7 @@ def launch(self, cmd: str, **kwargs: Dict) -> None: uri = web3.provider.endpoint_uri if web3.provider else None for i in range(100): if web3.isConnected(): - self._reset_id = self._snap() + self._reset_id = self._current_id = self._snap() _notify_registry(0) return time.sleep(0.1) @@ -127,7 +129,7 @@ def attach(self, laddr: Union[str, Tuple]) -> None: print(f"Attached to local RPC client listening at '{laddr[0]}:{laddr[1]}'...") self._rpc = psutil.Process(proc.pid) if web3.provider: - self._reset_id = self._snap() + self._reset_id = self._current_id = self._snap() _notify_registry(0) def kill(self, exc: bool = True) -> None: @@ -154,6 +156,9 @@ def kill(self, exc: bool = True) -> None: self._time_offset = 0 self._snapshot_id = False self._reset_id = False + self._current_id = False + self._undo_buffer.clear() + self._redo_buffer.clear() self._rpc = None _notify_registry(0) @@ -181,14 +186,58 @@ def _revert(self, id_: int) -> int: _notify_registry() return id_ - def _internal_snap(self) -> None: - self._internal_id = self._snap() + def _add_to_undo_buffer(self, fn: Any, args: Tuple, kwargs: Dict) -> None: + self._undo_buffer.append((self._current_id, fn, args, kwargs)) + if self._redo_buffer and (fn, args, kwargs) == self._redo_buffer[-1]: + self._redo_buffer.pop() + else: + self._redo_buffer.clear() + self._current_id = self._snap() + + def undo(self, num: int = 1) -> str: + """ + Undo one or more transactions. + + Arguments + --------- + num : int, optional + Number of transactions to undo. + """ + if num < 1: + raise ValueError("num must be greater than zero") + if not self._undo_buffer: + raise ValueError("Undo buffer is empty") + if num > len(self._undo_buffer): + raise ValueError(f"Undo buffer contains {len(self._undo_buffer)} items") + + for i in range(num, 0, -1): + id_, fn, args, kwargs = self._undo_buffer.pop() + self._redo_buffer.append((fn, args, kwargs)) + + self._current_id = self._revert(id_) + return f"Block height at {web3.eth.blockNumber}" - def _internal_revert(self) -> None: - self._request("evm_revert", [self._internal_id]) - self._internal_id = None - self.sleep(0) - _notify_registry() + def redo(self, num: int = 1) -> str: + """ + Redo one or more undone transactions. + + Arguments + --------- + num : int, optional + Number of transactions to redo. + """ + if num < 1: + raise ValueError("num must be greater than zero") + if not self._redo_buffer: + raise ValueError("Redo buffer is empty") + if num > len(self._redo_buffer): + raise ValueError(f"Redo buffer contains {len(self._redo_buffer)} items") + + for i in range(num, 0, -1): + fn, args, kwargs = self._redo_buffer[-1] + fn(*args, **kwargs) + + return f"Block height at {web3.eth.blockNumber}" def is_active(self) -> bool: """Returns True if Rpc client is currently active.""" @@ -255,23 +304,39 @@ def mine(self, blocks: int = 1) -> str: return f"Block height at {web3.eth.blockNumber}" def snapshot(self) -> str: - """Takes a snapshot of the current state of the EVM.""" - self._snapshot_id = self._snap() + """ + Take a snapshot of the current state of the EVM. + + This action clears the undo buffer. + """ + self._undo_buffer.clear() + self._redo_buffer.clear() + self._snapshot_id = self._current_id = self._snap() return f"Snapshot taken at block height {web3.eth.blockNumber}" def revert(self) -> str: - """Reverts the EVM to the most recently taken snapshot.""" + """ + Revert the EVM to the most recently taken snapshot. + + This action clears the undo buffer. + """ if not self._snapshot_id: raise ValueError("No snapshot set") - self._internal_id = None - self._snapshot_id = self._revert(self._snapshot_id) + self._undo_buffer.clear() + self._redo_buffer.clear() + self._snapshot_id = self._current_id = self._revert(self._snapshot_id) return f"Block height reverted to {web3.eth.blockNumber}" def reset(self) -> str: - """Reverts the EVM to the genesis state.""" + """ + Revert the EVM to the initial state when loaded. + + This action clears the undo buffer. + """ self._snapshot_id = None - self._internal_id = None - self._reset_id = self._revert(self._reset_id) + self._undo_buffer.clear() + self._redo_buffer.clear() + self._reset_id = self._current_id = self._revert(self._reset_id) return f"Block height reset to {web3.eth.blockNumber}" diff --git a/docs/api-network.rst b/docs/api-network.rst index 931663f52..159c0a02d 100644 --- a/docs/api-network.rst +++ b/docs/api-network.rst @@ -1616,20 +1616,39 @@ Rpc Methods >>> accounts[0].balance() 100000000000000000000 -Rpc Internal Methods -******************** +.. py:classmethod:: Rpc.undo(num=1) + + Undo one or more recent transactions. + + * ``num``: Number of transactions to undo + + Once undone, a transaction can be repeated using :func:`Rpc.redo `. Calling :func:`Rpc.snapshot ` or :func:`Rpc.revert ` clears the undo buffer. -.. py:classmethod:: Rpc._internal_snap() + .. code-block:: python + + >>> web3.eth.blockNumber + 3 + >>> rpc.undo() + 'Block height at 2' - Takes an internal snapshot at the current block height. -.. py:classmethod:: Rpc._internal_revert() +.. py:classmethod:: Rpc.redo(num=1) - Reverts to the most recently taken internal snapshot. + Redo one or more recently undone transactions. + + * ``num``: Number of transactions to redo + + .. code-block:: python + + >>> web3.eth.blockNumber + 2 + >>> rpc.redo() + Transaction sent: 0x8c166b66b356ad7f5c58337973b89950f03105cdae896ac66f16cdd4fc395d05 + Gas price: 0.0 gwei Gas limit: 6721975 + Transaction confirmed - Block: 3 Gas used: 21000 (0.31%) - .. note:: + 'Block height at 3' - When calling this method, you must ensure that the user has not had a chance to take their own snapshot since :func:`_internal_snap ` was called. Internal Methods ---------------- diff --git a/docs/core-rpc.rst b/docs/core-rpc.rst index 2e4a5ec7f..95d5c74fd 100644 --- a/docs/core-rpc.rst +++ b/docs/core-rpc.rst @@ -77,3 +77,29 @@ To return to the genesis state, use :func:`rpc.reset `. >>> rpc.reset() >>> web3.eth.blockNumber 0 + +Undo / Redo +----------- + +Along with snapshotting, you can use :func:`rpc.undo ` and :func:`rpc.redo ` to move backward and forward through recent transactions. This is especially useful during :ref:`interactive test debugging `. + +.. code-block:: python + + >>> accounts[0].transfer(accounts[1], "1 ether") + Transaction sent: 0x8c166b66b356ad7f5c58337973b89950f03105cdae896ac66f16cdd4fc395d05 + Gas price: 0.0 gwei Gas limit: 6721975 + Transaction confirmed - Block: 1 Gas used: 21000 (0.31%) + + + + >>> rpc.undo() + 'Block height at 0' + + >>> rpc.redo() + Transaction sent: 0x8c166b66b356ad7f5c58337973b89950f03105cdae896ac66f16cdd4fc395d05 + Gas price: 0.0 gwei Gas limit: 6721975 + Transaction confirmed - Block: 1 Gas used: 21000 (0.31%) + + 'Block height at 1' + +Note that :func:`rpc.snapshot ` and :func:`rpc.revert ` clear the undo buffer. diff --git a/docs/tests-pytest-intro.rst b/docs/tests-pytest-intro.rst index b32edc78e..6bddafc0f 100644 --- a/docs/tests-pytest-intro.rst +++ b/docs/tests-pytest-intro.rst @@ -358,6 +358,8 @@ Brownie compares hashes of the following items to check if a test should be re-r * The AST of the test module * The AST of all ``conftest.py`` modules that are accessible to the test module +.. _pytest-interactive: + Interactive Debugging --------------------- @@ -371,6 +373,7 @@ When using interactive mode, Brownie immediately prints the traceback for each f * Deployed :func:`ProjectContract ` objects are available within their associated :func:`ContractContainer ` * :func:`TransactionReceipt ` objects are in the :func:`TxHistory ` container, available as ``history`` +* Use :func:`rpc.undo ` and :func:`rpc.redo ` to move backward and forward through recent transactions Once you are finished, type ``quit()`` to continue with the next test. diff --git a/tests/network/contract/test_contractcall.py b/tests/network/contract/test_contractcall.py index a7fa51086..c575b954a 100644 --- a/tests/network/contract/test_contractcall.py +++ b/tests/network/contract/test_contractcall.py @@ -28,7 +28,7 @@ def test_always_transact(accounts, tester, argv, web3, monkeypatch, history): result = tester.owner() assert owner == result assert web3.eth.blockNumber == height == len(history) - monkeypatch.setattr("brownie.network.rpc._internal_revert", lambda: None) + monkeypatch.setattr("brownie.network.rpc.undo", lambda: None) result = tester.owner() tx = history[-1] assert owner == result diff --git a/tests/network/rpc/conftest.py b/tests/network/rpc/conftest.py index b6bce1a78..f414ffbbb 100644 --- a/tests/network/rpc/conftest.py +++ b/tests/network/rpc/conftest.py @@ -35,6 +35,7 @@ def _no_rpc_setup(rpc, web3, temp_port, original_port): _notify_registry(0) rpc._rpc = proc rpc._reset_id = reset_id + rpc._current_id = reset_id @pytest.fixture diff --git a/tests/network/rpc/test_redo.py b/tests/network/rpc/test_redo.py new file mode 100644 index 000000000..4a0bc284a --- /dev/null +++ b/tests/network/rpc/test_redo.py @@ -0,0 +1,73 @@ +#!/usr/bin/python3 + + +import pytest + + +def test_redo(accounts, rpc, web3): + accounts[0].transfer(accounts[1], "1 ether") + result = accounts[0].balance() + rpc.undo() + rpc.redo() + assert web3.eth.blockNumber == 1 + assert accounts[0].balance() == result + + +def test_redo_multiple(accounts, rpc, web3): + for i in range(1, 6): + accounts[0].transfer(accounts[i], "1 ether") + result = accounts[0].balance() + rpc.undo(5) + rpc.redo(5) + assert accounts[0].balance() == result + + +def test_redo_contract_tx(tester, accounts, rpc, history): + tester.receiveEth({"from": accounts[0], "amount": "1 ether"}) + rpc.undo() + rpc.redo() + assert history[-1].fn_name == "receiveEth" + + +def test_redo_deploy(BrownieTester, accounts, rpc): + BrownieTester.deploy(True, {"from": accounts[0]}) + rpc.undo() + rpc.redo() + assert len(BrownieTester) == 1 + + +def test_redo_empty_buffer(accounts, rpc): + with pytest.raises(ValueError): + rpc.redo() + + +def test_redo_zero(accounts, rpc): + with pytest.raises(ValueError): + rpc.redo(0) + + +def test_redo_too_many(accounts, rpc): + accounts[0].transfer(accounts[1], 100) + accounts[0].transfer(accounts[1], 100) + rpc.undo() + with pytest.raises(ValueError): + rpc.redo(2) + + +def test_snapshot_clears_redo_buffer(accounts, rpc): + accounts[0].transfer(accounts[1], 100) + accounts[0].transfer(accounts[1], 100) + rpc.undo() + rpc.snapshot() + with pytest.raises(ValueError): + rpc.redo() + + +def test_revert_clears_undo_buffer(accounts, rpc): + rpc.snapshot() + accounts[0].transfer(accounts[1], 100) + accounts[0].transfer(accounts[1], 100) + rpc.undo() + rpc.revert() + with pytest.raises(ValueError): + rpc.redo() diff --git a/tests/network/rpc/test_undo.py b/tests/network/rpc/test_undo.py new file mode 100644 index 000000000..370adffc5 --- /dev/null +++ b/tests/network/rpc/test_undo.py @@ -0,0 +1,52 @@ +#!/usr/bin/python3 + +import pytest + + +def test_undo(accounts, rpc, web3): + initial = accounts[0].balance() + accounts[0].transfer(accounts[1], "1 ether") + rpc.undo() + assert web3.eth.blockNumber == 0 + assert accounts[0].balance() == initial + + +def test_undo_multiple(accounts, rpc, web3): + initial = accounts[0].balance() + for i in range(1, 6): + accounts[0].transfer(accounts[i], "1 ether") + rpc.undo(5) + assert accounts[0].balance() == initial + + +def test_undo_empty_buffer(accounts, rpc): + with pytest.raises(ValueError): + rpc.undo() + + +def test_undo_zero(accounts, rpc): + accounts[0].transfer(accounts[1], 100) + with pytest.raises(ValueError): + rpc.undo(0) + + +def test_undo_too_many(accounts, rpc): + accounts[0].transfer(accounts[1], 100) + with pytest.raises(ValueError): + rpc.undo(2) + + +def test_snapshot_clears_undo_buffer(accounts, rpc): + accounts[0].transfer(accounts[1], 100) + rpc.snapshot() + with pytest.raises(ValueError): + rpc.undo() + + +def test_revert_clears_undo_buffer(accounts, rpc): + accounts[0].transfer(accounts[1], 100) + rpc.snapshot() + accounts[0].transfer(accounts[1], 100) + rpc.revert() + with pytest.raises(ValueError): + rpc.undo() diff --git a/tests/test/plugin/test_coverage.py b/tests/test/plugin/test_coverage.py index 38cb2efd1..f1c8f87fc 100644 --- a/tests/test/plugin/test_coverage.py +++ b/tests/test/plugin/test_coverage.py @@ -19,26 +19,22 @@ def setup(no_call_coverage): def test_always_transact(plugintester, mocker, rpc): - mocker.spy(rpc, "_internal_snap") - mocker.spy(rpc, "_internal_revert") + mocker.spy(rpc, "undo") result = plugintester.runpytest() result.assert_outcomes(passed=1) - assert rpc._internal_snap.call_count == 0 - assert rpc._internal_revert.call_count == 0 + assert rpc.undo.call_count == 0 # with coverage eval result = plugintester.runpytest("--coverage") result.assert_outcomes(passed=1) - assert rpc._internal_snap.call_count == 1 - assert rpc._internal_revert.call_count == 1 + assert rpc.undo.call_count == 1 # with coverage and no_call_coverage fixture plugintester.makeconftest(conf_source) result = plugintester.runpytest("--coverage") result.assert_outcomes(passed=1) - assert rpc._internal_snap.call_count == 1 - assert rpc._internal_revert.call_count == 1 + assert rpc.undo.call_count == 1 def test_coverage_tx(json_path, plugintester):