Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rpc.undo and Rpc.redo #457

Merged
merged 4 commits into from
Apr 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions brownie/network/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)


Expand Down
3 changes: 1 addition & 2 deletions brownie/network/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 83 additions & 18 deletions brownie/network/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)

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


Expand Down
35 changes: 27 additions & 8 deletions docs/api-network.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Rpc.redo>`. Calling :func:`Rpc.snapshot <Rpc.snapshot>` or :func:`Rpc.revert <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 <Rpc._internal_snap>` was called.

Internal Methods
----------------
Expand Down
26 changes: 26 additions & 0 deletions docs/core-rpc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,29 @@ To return to the genesis state, use :func:`rpc.reset <Rpc.reset>`.
>>> rpc.reset()
>>> web3.eth.blockNumber
0

Undo / Redo
-----------

Along with snapshotting, you can use :func:`rpc.undo <Rpc.undo>` and :func:`rpc.redo <Rpc.redo>` to move backward and forward through recent transactions. This is especially useful during :ref:`interactive test debugging <pytest-interactive>`.

.. 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%)

<Transaction '0x8c166b66b356ad7f5c58337973b89950f03105cdae896ac66f16cdd4fc395d05'>

>>> 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 <Rpc.snapshot>` and :func:`rpc.revert <Rpc.revert>` clear the undo buffer.
3 changes: 3 additions & 0 deletions docs/tests-pytest-intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------

Expand All @@ -371,6 +373,7 @@ When using interactive mode, Brownie immediately prints the traceback for each f

* Deployed :func:`ProjectContract <brownie.network.contract.ProjectContract>` objects are available within their associated :func:`ContractContainer <brownie.network.contract.ContractContainer>`
* :func:`TransactionReceipt <brownie.network.transaction.TransactionReceipt>` objects are in the :func:`TxHistory <brownie.network.state.TxHistory>` container, available as ``history``
* Use :func:`rpc.undo <Rpc.undo>` and :func:`rpc.redo <Rpc.redo>` to move backward and forward through recent transactions

Once you are finished, type ``quit()`` to continue with the next test.

Expand Down
2 changes: 1 addition & 1 deletion tests/network/contract/test_contractcall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/network/rpc/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions tests/network/rpc/test_redo.py
Original file line number Diff line number Diff line change
@@ -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()
Loading