diff --git a/CHANGELOG b/CHANGELOG index 5a3d3a4dc..6b4e05b92 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,12 @@ +0.9.4 +----- + + - Improved console formatting for lists and dicts + - Run method returns list of scripts when no argument is given + - Do not keep mnemonics and private keys in readline history + - Use KwargTuple type for call return values + - Bugfixes + 0.9.3 ----- diff --git a/__main__.py b/__main__.py index 9c65dcf1e..ea592cd12 100644 --- a/__main__.py +++ b/__main__.py @@ -9,7 +9,7 @@ from lib.services import color, git -__version__ = "0.9.3" # did you change this in docs/conf.py as well? +__version__ = "0.9.4" # did you change this in docs/conf.py as well? if git.get_branch() != "master": __version__+= "-"+git.get_branch()+"-"+git.get_commit() diff --git a/docs/api.rst b/docs/api.rst index b9a15101a..36172f350 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1096,12 +1096,16 @@ These methods are used in the console. Brownie environment is ready. >>> -.. py:method:: run(script) +.. py:method:: run(script=None) Loads a script and runs the ``main`` method within it. See :ref:`deploy` for more information. + If no argument is given, returns a list of script names from the ``scripts/`` folder. + .. code-block:: python + >>> run() + ['token'] >>> run('token') Transaction sent: 0xe4bd74210e56d4da8d53774dc333a1122c26a72a86fbba82220fcf5d2648d634 diff --git a/docs/conf.py b/docs/conf.py index 7a7d2a719..5216401d3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ # The short X.Y version version = '' # The full version, including alpha/beta/rc tags -release = '0.9.3' +release = '0.9.4' # -- General configuration --------------------------------------------------- diff --git a/lib/components/account.py b/lib/components/account.py index 49fca39dd..dba980f21 100644 --- a/lib/components/account.py +++ b/lib/components/account.py @@ -2,7 +2,6 @@ import eth_keys from hexbytes import HexBytes -import json import os from lib.components.transaction import TransactionReceipt, raise_or_return_tx @@ -18,6 +17,9 @@ class Accounts: def __init__(self, accounts): self._accounts = [Account(i) for i in accounts] + # prevent mnemonics and private keys from being stored in read history + self.add.__dict__['_private'] = True + self.mnemonic.__dict__['_private'] = True def __contains__(self, address): try: @@ -26,9 +28,9 @@ def __contains__(self, address): except ValueError: return False - def __repr__(self): + def _console_repr(self): return str(self._accounts) - + def __iter__(self): return iter(self._accounts) @@ -40,14 +42,14 @@ def __delitem__(self, key): def __len__(self): return len(self._accounts) - + def _check_nonce(self): for i in self._accounts: i.nonce = web3.eth.getTransactionCount(str(i)) - def add(self, priv_key = None): + def add(self, priv_key=None): '''Creates a new ``LocalAccount`` instance and appends it to the container. - + Args: priv_key: Private key of the account. If none is given, one is randomly generated. @@ -56,7 +58,7 @@ def add(self, priv_key = None): Account instance. ''' if not priv_key: - priv_key=web3.sha3(os.urandom(8192)).hex() + priv_key = web3.sha3(os.urandom(8192)).hex() w3account = web3.eth.account.privateKeyToAccount(priv_key) if w3account.address in self._accounts: return self.at(w3account.address) @@ -112,7 +114,7 @@ def clear(self): class _AccountBase: '''Base class for Account and LocalAccount''' - + def __init__(self, addr): self.address = addr self.nonce = web3.eth.getTransactionCount(self.address) @@ -120,6 +122,9 @@ def __init__(self, addr): def __hash__(self): return hash(self.address) + def __repr__(self): + return "'{0[string]}{1}{0}'".format(color, self.address) + def __str__(self): return self.address @@ -148,7 +153,7 @@ def deploy(self, contract, *args): * Contract instance if the transaction confirms * TransactionReceipt if the transaction is pending or reverts''' return contract.deploy(self, *args) - + def estimate_gas(self, to, amount, data=""): '''Estimates the gas cost for a transaction. Raises VirtualMachineError if the transaction would revert. @@ -161,10 +166,10 @@ def estimate_gas(self, to, amount, data=""): Returns: Estimated gas value in wei.''' return web3.eth.estimateGas({ - 'from':self.address, - 'to':str(to), - 'data':data, - 'value':wei(amount) + 'from': self.address, + 'to': str(to), + 'data': data, + 'value': wei(amount) }) def _gas_limit(self, to, amount, data=""): @@ -179,17 +184,17 @@ def _gas_price(self): class Account(_AccountBase): '''Class for interacting with an Ethereum account. - + Attributes: address: Public address of the account. nonce: Current nonce of the account.''' - - def __repr__(self): + + def _console_repr(self): return "".format(color, self.address) - + def transfer(self, to, amount, gas_limit=None, gas_price=None): '''Transfers ether from this account. - + Args: to: Account instance or address string to transfer to. amount: Amount of ether to send, in wei. @@ -228,7 +233,7 @@ def _contract_tx(self, fn, args, tx, name, callback=None): class LocalAccount(_AccountBase): '''Class for interacting with an Ethereum account. - + Attributes: address: Public address of the account. nonce: Current nonce of the account. @@ -241,12 +246,12 @@ def __init__(self, address, account, priv_key): self.public_key = eth_keys.keys.PrivateKey(HexBytes(priv_key)).public_key super().__init__(address) - def __repr__(self): + def _console_repr(self): return "".format(color, self.address) def transfer(self, to, amount, gas_limit=None, gas_price=None): '''Transfers ether from this account. - + Args: to: Account instance or address string to transfer to. amount: Amount of ether to send, in wei. @@ -274,8 +279,8 @@ def transfer(self, to, amount, gas_limit=None, gas_price=None): def _contract_tx(self, fn, args, tx, name, callback=None): try: tx.update({ - 'from':self.address, - 'nonce':self.nonce, + 'from': self.address, + 'nonce': self.nonce, 'gasPrice': self._gas_price(), 'gas': ( CONFIG['active_network']['gas_limit'] or diff --git a/lib/components/check.py b/lib/components/check.py index b7e14f949..930f5a600 100644 --- a/lib/components/check.py +++ b/lib/components/check.py @@ -8,7 +8,7 @@ def true(statement, fail_msg="Expected statement to be true"): '''Expects an object or statement to evaluate True. - + Args: statement: The object or statement to check. fail_msg: Message to show if the check fails.''' @@ -18,7 +18,7 @@ def true(statement, fail_msg="Expected statement to be true"): def false(statement, fail_msg="Expected statement to be False"): '''Expects an object or statement to evaluate False. - + Args: statement: The object or statement to check. fail_msg: Message to show if the check fails.''' @@ -28,14 +28,14 @@ def false(statement, fail_msg="Expected statement to be False"): def reverts(fn, args, revert_msg=None): '''Expects a transaction to revert. - + Args: fn: ContractTx instance to call. args: List or tuple of contract input args. fail_msg: Message to show if the check fails. revert_msg: If set, the check only passes if the returned revert message matches the given one.''' - try: + try: fn(*args) except _VMError as e: if not revert_msg or revert_msg == e.revert_msg: @@ -49,12 +49,12 @@ def reverts(fn, args, revert_msg=None): def confirms(fn, args, fail_msg="Expected transaction to confirm"): '''Expects a transaction to confirm. - + Args: fn: ContractTx instance to call. args: List or tuple of contract input args. fail_msg: Message to show if the check fails. - + Returns: TransactionReceipt instance.''' try: @@ -66,7 +66,7 @@ def confirms(fn, args, fail_msg="Expected transaction to confirm"): def event_fired(tx, name, count=None, values=None): '''Expects a transaction to contain an event. - + Args: tx: A TransactionReceipt. name: Name of the event expected to fire. @@ -75,7 +75,7 @@ def event_fired(tx, name, count=None, values=None): values: A dict or list of dicts of {key:value} that must match against the fired events. The length of values must also match the number of events that fire.''' - events = [i for i in tx.events if i['name']==name] + events = [i for i in tx.events if i['name'] == name] if count is not None and count != len(events): raise AssertionError( "Event {} - expected {} events to fire, got {}".format( @@ -94,12 +94,12 @@ def event_fired(tx, name, count=None, values=None): ) ) for i in range(len(values)): - data = dict((x['name'],x['value']) for x in events[i]['data']) - for k,v in values[i].items(): + data = dict((x['name'], x['value']) for x in events[i]['data']) + for k, v in values[i].items(): if k not in data: print(data) raise KeyError( - "Event {} - does not contain value '{}'".format(name,k) + "Event {} - does not contain value '{}'".format(name, k) ) if data[k] != v: raise AssertionError( @@ -111,12 +111,12 @@ def event_fired(tx, name, count=None, values=None): def event_not_fired(tx, name, fail_msg="Expected event not to fire"): '''Expects a transaction not to contain an event. - + Args: tx: A TransactionReceipt. name: Name of the event expected to fire. fail_msg: Message to show if check fails.''' - if [i for i in tx.events if i['name']==name]: + if [i for i in tx.events if i['name'] == name]: raise AssertionError(fail_msg) @@ -129,7 +129,7 @@ def equal(a, b, fail_msg="Expected values to be equal"): fail_msg: Message to show if check fails.''' a, b = _convert(a, b) if a != b: - raise AssertionError(fail_msg+": {} != {}".format(a,b)) + raise AssertionError(fail_msg+": {} != {}".format(a, b)) def not_equal(a, b, fail_msg="Expected values to be not equal"): @@ -141,15 +141,19 @@ def not_equal(a, b, fail_msg="Expected values to be not equal"): fail_msg: Message to show if check fails.''' a, b = _convert(a, b) if a == b: - raise AssertionError(fail_msg+": {} == {}".format(a,b)) + raise AssertionError(fail_msg+": {} == {}".format(a, b)) # attempt conversion with wei before comparing equality def _convert(a, b): if a not in (None, False, True): - try: a = wei(a) - except ValueError: pass + try: + a = wei(a) + except (ValueError, TypeError): + pass if b not in (None, False, True): - try: b = wei(b) - except ValueError: pass - return a, b \ No newline at end of file + try: + b = wei(b) + except (ValueError, TypeError): + pass + return a, b diff --git a/lib/components/contract.py b/lib/components/contract.py index 097355567..f0469c11e 100644 --- a/lib/components/contract.py +++ b/lib/components/contract.py @@ -4,6 +4,7 @@ import eth_event import re +from lib.services.datatypes import KwargTuple, format_output from lib.components.transaction import TransactionReceipt, VirtualMachineError from lib.components.eth import web3, wei @@ -14,6 +15,7 @@ deployed_contracts = {} + def find_contract(address): address = web3.toChecksumAddress(address) contracts = [x for v in deployed_contracts.values() for x in v.values()] @@ -26,43 +28,43 @@ def __init__(self, build): self._build = build self.abi = build['abi'] self._name = build['contractName'] - names = [i['name'] for i in self.abi if i['type']=="function"] - duplicates = set(i for i in names if names.count(i)>1) + names = [i['name'] for i in self.abi if i['type'] == "function"] + duplicates = set(i for i in names if names.count(i) > 1) if duplicates: raise AttributeError("Ambiguous contract functions in {}: {}".format( self._name, ",".join(duplicates))) self.topics = eth_event.get_topics(self.abi) self.signatures = dict(( i['name'], - web3.sha3(text="{}({})".format(i['name'], - ",".join(x['type'] for x in i['inputs']) - )).hex()[:10] - ) for i in self.abi if i['type']=="function") + web3.sha3(text="{}({})".format( + i['name'], ",".join(x['type'] for x in i['inputs']) + )).hex()[:10] + ) for i in self.abi if i['type'] == "function") class ContractContainer(_ContractBase): '''List-like container class that holds all Contract instances of the same type, and is used to deploy new instances of that contract. - + Attributes: abi: Complete contract ABI. bytecode: Bytecode used to deploy the contract. signatures: Dictionary of {'function name': "bytes4 signature"} topics: Dictionary of {'event name': "bytes32 topic"}''' - + def __init__(self, build, network): self.tx = None self.bytecode = build['bytecode'] self._network = network if type(build['pcMap']) is list: - build['pcMap'] = dict((i.pop('pc'),i) for i in build['pcMap']) + build['pcMap'] = dict((i.pop('pc'), i) for i in build['pcMap']) super().__init__(build) self.deploy = ContractConstructor(self, self._name) deployed_contracts[self._name] = OrderedDict() for k, data in sorted([ - (k,v) for k,v in build['networks'].items() if - v['network']==CONFIG['active_network']['name'] + (k, v) for k, v in build['networks'].items() if + v['network'] == CONFIG['active_network']['name'] ], key=lambda k: int(k[0])): if web3.eth.getCode(data['address']).hex() == "0x00": print("WARNING: No contract deployed at {}.".format(data['address'])) @@ -85,23 +87,23 @@ def __getitem__(self, i): def __delitem__(self, key): del deployed_contracts[self._name][self[key].address] - + def __len__(self): return len(deployed_contracts[self._name]) - def __repr__(self): + def _console_repr(self): return str(list(deployed_contracts[self._name].values())) def remove(self, contract): '''Removes a contract from the container. - + Args: contract: Contract instance of address string of the contract.''' del deployed_contracts[self._name][str(contract)] - def at(self, address, owner = None, tx = None): + def at(self, address, owner=None, tx=None): '''Returns a contract address. - + Raises ValueError if no bytecode exists at the address. Args: @@ -126,14 +128,14 @@ def at(self, address, owner = None, tx = None): class ContractConstructor: - + def __init__(self, parent, name): self._parent = parent try: - self.abi = [next(i for i in parent.abi if i['type']=="constructor")] + self.abi = [next(i for i in parent.abi if i['type'] == "constructor")] except: self.abi = [] - + self._name = name def __repr__(self): @@ -174,7 +176,7 @@ def __call__(self, account, *args): marker, self._parent._network[contract][-1].address[-40:] ) - contract = web3.eth.contract(abi = self.abi, bytecode = bytecode) + contract = web3.eth.contract(abi=self.abi, bytecode=bytecode) args, tx = _get_tx(account, args) tx = account._contract_tx( contract.constructor, @@ -201,7 +203,7 @@ def _callback(self, tx): class Contract(_ContractBase): '''Methods for interacting with a deployed contract. - + Each public contract method is available as a ContractCall or ContractTx instance, created when this class is instantiated. @@ -214,15 +216,15 @@ def __init__(self, address, build, owner, tx=None): self.tx = tx self.bytecode = web3.eth.getCode(address).hex()[2:] self._owner = owner - self._contract = web3.eth.contract(address = address, abi = self.abi) - for i in [i for i in self.abi if i['type']=="function"]: + self._contract = web3.eth.contract(address=address, abi=self.abi) + for i in [i for i in self.abi if i['type'] == "function"]: if hasattr(self, i['name']): raise AttributeError( "Namespace collision: '{}.{}'".format(self._name, i['name']) ) - fn = getattr(self._contract.functions,i['name']) + fn = getattr(self._contract.functions, i['name']) name = "{}.{}".format(self._name, i['name']) - if i['stateMutability'] in ('view','pure'): + if i['stateMutability'] in ('view', 'pure'): setattr(self, i['name'], ContractCall(fn, i, name, owner)) else: setattr(self, i['name'], ContractTx(fn, i, name, owner)) @@ -274,7 +276,7 @@ def _format_inputs(self, args): def call(self, *args): '''Calls the contract method without broadcasting a transaction. - + Args: *args: Contract method inputs. You can optionally provide a dictionary of transaction properties as the last arg. @@ -286,17 +288,18 @@ def call(self, *args): tx['from'] = str(tx['from']) else: del tx['from'] - try: + try: result = self._fn(*self._format_inputs(args)).call(tx) except ValueError as e: raise VirtualMachineError(e) - if type(result) is not list: - return web3.toHex(result) if type(result) is bytes else result - return [(web3.toHex(i) if type(i) is bytes else i) for i in result] + + if type(result) is not list or len(result)==1: + return format_output(result) + return KwargTuple(result, self.abi) def transact(self, *args): '''Broadcasts a transaction that calls this contract method. - + Args: *args: Contract method inputs. You can optionally provide a dictionary of transaction properties as the last arg. @@ -335,7 +338,7 @@ def __init__(self, fn, abi, name, owner): def __call__(self, *args): '''Broadcasts a transaction that calls this contract method. - + Args: *args: Contract method inputs. You can optionally provide a dictionary of transaction properties as the last arg. @@ -348,21 +351,21 @@ def __call__(self, *args): class ContractCall(_ContractMethod): '''A public view or pure contract method. - + Args: abi: Contract ABI specific to this method. signature: Bytes4 method signature.''' def __call__(self, *args): '''Calls the contract method without broadcasting a transaction. - + Args: *args: Contract method inputs. You can optionally provide a dictionary of transaction properties as the last arg. Returns: Contract method return value(s).''' - if config.ARGV['mode']=="script" and CONFIG['test']['always_transact']: + if config.ARGV['mode'] == "script" and CONFIG['test']['always_transact']: tx = self.transact(*args) return tx.return_value return self.call(*args) @@ -374,7 +377,7 @@ def _get_tx(owner, args): args, tx = (args[:-1], args[-1]) if 'from' not in tx: tx['from'] = owner - for key in [i for i in ['value','gas','gasPrice'] if i in tx]: + for key in [i for i in ('value', 'gas', 'gasPrice') if i in tx]: tx[key] = wei(tx[key]) else: tx = {'from': owner} @@ -387,19 +390,19 @@ def _format_inputs(name, inputs, types): if len(inputs) and not len(types): raise AttributeError("{} requires no arguments".format(name)) if len(inputs) != len(types): - raise AttributeError( - "{} requires the following arguments: {}".format( - name,",".join(types))) + raise AttributeError("{} requires the following arguments: {}".format( + name, ",".join(types) + )) for i, type_ in enumerate(types): - if type_[-1]=="]": + if type_[-1] == "]": # input value is an array, have to check every item - t,length = type_.rstrip(']').rsplit('[', maxsplit=1) + t, length = type_.rstrip(']').rsplit('[', maxsplit=1) if length != "" and len(inputs[i]) != int(length): raise ValueError( "'{}': Argument {}, sequence has a ".format(name, i) + "length of {}, should be {}".format(len(inputs[i]), type_) ) - inputs[i] = _format_inputs(name, inputs[i],[t]*len(inputs[i])) + inputs[i] = _format_inputs(name, inputs[i], [t]*len(inputs[i])) continue try: if "address" in type_: @@ -407,12 +410,15 @@ def _format_inputs(name, inputs, types): if "int" in type_: inputs[i] = wei(inputs[i]) elif "bytes" in type_ and type(inputs[i]) is not bytes: - if type(inputs[i]) is not str: - inputs[i]=int(inputs[i]).to_bytes(int(type_[5:]), "big") - elif inputs[i][:2]!="0x": - inputs[i]=inputs[i].encode() + if type(inputs[i]) is str: + if inputs[i][:2] != "0x": + inputs[i] = inputs[i].encode() + elif type_ != "bytes": + inputs[i] = int(inputs[i], 16).to_bytes(int(type_[5:]), "big") + else: + inputs[i] = int(inputs[i]).to_bytes(int(type_[5:]), "big") except: raise ValueError( "'{}': Argument {}, could not convert {} '{}' to type {}".format( - name,i,type(inputs[i]).__name__,inputs[i],type_)) - return inputs \ No newline at end of file + name, i, type(inputs[i]).__name__, inputs[i], type_)) + return inputs diff --git a/lib/components/eth.py b/lib/components/eth.py index ae2a6547d..326450fea 100644 --- a/lib/components/eth.py +++ b/lib/components/eth.py @@ -19,7 +19,7 @@ def __init__(self): def _connect(self): web3 = Web3(HTTPProvider(CONFIG['active_network']['host'])) - for name, fn in [(i,getattr(web3,i)) for i in dir(web3) if i[0].islower()]: + for name, fn in [(i, getattr(web3, i)) for i in dir(web3) if i[0].islower()]: setattr(self, name, fn) for i in range(20): if web3.isConnected(): @@ -29,18 +29,19 @@ def _connect(self): CONFIG['active_network']['host'] )) + class Rpc: '''Methods for interacting with ganache-cli when running a local RPC environment.''' - + def __init__(self, network): rpc = Popen( CONFIG['active_network']['test-rpc'].split(' '), - stdout = DEVNULL, - stdin = DEVNULL, - stderr = DEVNULL, - start_new_session = True + stdout=DEVNULL, + stdin=DEVNULL, + stderr=DEVNULL, + start_new_session=True ) self._rpc = rpc self._time_offset = 0 @@ -60,7 +61,7 @@ def time(self): def sleep(self, seconds): '''Increases the time within the test RPC. - + Args: seconds (int): Number of seconds to increase the time by.''' if type(seconds) is not int: @@ -69,7 +70,7 @@ def sleep(self, seconds): "evm_increaseTime", [seconds] )['result'] - def mine(self, blocks = 1): + def mine(self, blocks=1): '''Increases the block height within the test RPC. Args: @@ -77,20 +78,21 @@ def mine(self, blocks = 1): if type(blocks) is not int: raise TypeError("blocks must be an integer value") for i in range(blocks): - web3.providers[0].make_request("evm_mine",[]) + web3.providers[0].make_request("evm_mine", []) return "Block height at {}".format(web3.eth.blockNumber) def snapshot(self): '''Takes a snapshot of the current state of the EVM.''' - self._snapshot_id = web3.providers[0].make_request("evm_snapshot",[])['result'] + self._snapshot_id = web3.providers[0].make_request("evm_snapshot", [])['result'] return "Snapshot taken at block height {}".format(web3.eth.blockNumber) def revert(self): '''Reverts the EVM to the most recently taken snapshot.''' if not self._snapshot_id: raise ValueError("No snapshot set") - web3.providers[0].make_request("evm_revert",[self._snapshot_id]) + web3.providers[0].make_request("evm_revert", [self._snapshot_id]) self.snapshot() + self.sleep(0) self._network._network_dict['accounts']._check_nonce() height = web3.eth.blockNumber history = self._network._network_dict['history'] @@ -113,7 +115,7 @@ def _watch_rpc(rpc): def wei(value): '''Converts a value to wei. - + Useful for the following formats: * a string specifying the unit: "10 ether", "300 gwei", "0.25 shannon" * a large float in scientific notation, where direct conversion to int @@ -141,11 +143,11 @@ def wei(value): try: return int(value) except ValueError: - raise ValueError("Unknown denomination: {}".format(value)) + raise ValueError("Unknown denomination: {}".format(value)) web3 = web3() UNITS = { 'kwei': 3, 'babbage': 3, 'mwei': 6, 'lovelace': 6, 'gwei': 9, 'shannon': 9, 'microether': 12, 'szabo': 12, 'milliether': 15, 'finney': 15, 'ether': 18 -} \ No newline at end of file +} diff --git a/lib/components/network.py b/lib/components/network.py index 65e2f85f7..44e710d5b 100644 --- a/lib/components/network.py +++ b/lib/components/network.py @@ -161,11 +161,13 @@ def save(self): print("{0[error]}ERROR{0}: Unable to save environment due to unhandled {1}: {2}".format( color, type(e).__name__, e)) - def run(self, name): + def run(self, name=None): '''Loads a module from the scripts/ folder and runs the main() method. Args: name (string): name of the script.''' + if not name: + return([i[:-3] for i in os.listdir('scripts') if i[0]!="_" and i[-3:]==".py"]) if not os.path.exists("scripts/{}.py".format(name)): print("{0[error]}ERROR{0}: Cannot find scripts/{1}.py".format(color, name)) return diff --git a/lib/components/transaction.py b/lib/components/transaction.py index 1d72ecd0e..9cccb1c27 100644 --- a/lib/components/transaction.py +++ b/lib/components/transaction.py @@ -10,6 +10,7 @@ from lib.components import contract from lib.components.eth import web3 from lib.services.compiler import compile_contracts +from lib.services.datatypes import KwargTuple, format_output from lib.services import config from lib.services import color CONFIG = config.CONFIG @@ -33,28 +34,33 @@ class VirtualMachineError(Exception): '''Raised when a call to a contract causes an EVM exception. - + Attributes: revert_msg: The returned error string, if any. source: The contract source code where the revert occured, if available.''' revert_msg = "" source = "" - + def __init__(self, exc): if type(exc) is not dict: - exc = eval(str(exc)) + try: + exc = eval(str(exc)) + except SyntaxError: + exc = {'message': str(exc)} + if len(exc['message'].split('revert ', maxsplit=1)) > 1: + self.revert_msg = exc['message'].split('revert ')[-1] if 'source' in exc: self.source = exc['source'] - if len(exc['message'].split('revert ', maxsplit=1))>1: - self.revert_msg = exc['message'].split('revert ')[-1] - super().__init__(exc['message']) + super().__init__(exc['message']+"\n"+exc['source']) + else: + super().__init__(exc['message']) def raise_or_return_tx(exc): data = eval(str(exc)) try: - return next(i for i in data['data'].keys() if i[:2]=="0x") + return next(i for i in data['data'].keys() if i[:2] == "0x") except Exception: raise VirtualMachineError(exc) @@ -67,7 +73,7 @@ class TransactionReceipt: * Before the tx confirms, many values are set to None. * trace, revert_msg return_value, and events from a reverted tx are only available if debug_traceTransaction is enabled in the RPC. - + Attributes: fn_name: Name of the method called in the transaction txid: Transaction ID @@ -121,9 +127,10 @@ def __init__(self, txid, sender=None, silent=False, name='', callback=None): try: t.join() if config.ARGV['mode'] == "script" and not self.status: - raise VirtualMachineError( - {"message": "revert "+(self.revert_msg or ""), "source":self.error(1)} - ) + raise VirtualMachineError({ + "message": "revert "+(self.revert_msg or ""), + "source": self.error(1) + }) except KeyboardInterrupt: if config.ARGV['mode'] == "script": raise @@ -131,7 +138,8 @@ def __init__(self, txid, sender=None, silent=False, name='', callback=None): def _await_confirm(self, silent, callback): while True: tx = web3.eth.getTransaction(self.txid) - if tx: break + if tx: + break time.sleep(0.5) if not self.sender: self.sender = tx['from'] @@ -190,7 +198,7 @@ def __hash__(self): return hash(self.txid) def __getattr__(self, attr): - if attr not in ('events','return_value', 'revert_msg', 'trace'): + if attr not in ('events', 'return_value', 'revert_msg', 'trace'): raise AttributeError( "'TransactionReceipt' object has no attribute '{}'".format(attr) ) @@ -227,7 +235,7 @@ def info(self): for i in event['data']: color.print_colors( " {0[name]}: {0[value]}".format(i), - value = None if i['decoded'] else "dull" + value=None if i['decoded'] else "dull" ) print() @@ -244,11 +252,11 @@ def _get_trace(self): self.trace = [] self.return_value = None self.revert_msg = None - if (self.input=="0x" and self.gas_used == 21000) or self.contract_address: + if (self.input == "0x" and self.gas_used == 21000) or self.contract_address: return trace = web3.providers[0].make_request( 'debug_traceTransaction', - [self.txid,{}] + [self.txid, {}] ) if 'error' in trace: raise ValueError(trace['error']['message']) @@ -256,7 +264,7 @@ def _get_trace(self): c = contract.find_contract(self.receiver or self.contract_address) last = {0: { 'address': self.receiver or self.contract_address, - 'contract':c._name, + 'contract': c._name, 'fn': [self.fn_name.split('.')[-1]], }} pc = c._build['pcMap'][0] @@ -282,7 +290,7 @@ def _get_trace(self): last[trace[i]['depth']] = { 'address': address, 'contract': c._name, - 'fn': [next(k for k,v in c.signatures.items() if v==sig)], + 'fn': [next(k for k, v in c.signatures.items() if v == sig)], } trace[i].update({ 'address': last[trace[i]['depth']]['address'], @@ -304,9 +312,9 @@ def _get_trace(self): fn = source[:source.index('(')].split('.')[-1] else: fn = last[trace[i]['depth']]['fn'][-1] - last[trace[i]['depth']]['fn'].append(fn) + last[trace[i]['depth']]['fn'].append(fn) # jump 'o' is coming out of an internal function - elif pc['jump'] == "o" and len(last[trace[i]['depth']]['fn'])>1 : + elif pc['jump'] == "o" and len(last[trace[i]['depth']]['fn']) > 1: last[trace[i]['depth']]['fn'].pop() def _evaluate_trace(self): @@ -330,9 +338,13 @@ def _evaluate_trace(self): self.return_value = eth_abi.decode_abi(abi, data) if not self.return_value: return - self.return_value = [_decode_abi(i) for i in self.return_value] if len(self.return_value) == 1: - self.return_value = self.return_value[0] + self.return_value = format_output(self.return_value[0]) + else: + self.return_value = KwargTuple( + self.return_value, + getattr(c, self.fn_name.split('.')[-1]).abi + ) else: self.events = [] # get revert message @@ -349,7 +361,7 @@ def _evaluate_trace(self): self.events = eth_event.decode_trace(self.trace, topics()) except: pass - + def call_trace(self): '''Displays the sequence of contracts and functions called while executing this transaction, and the structLog index where each call @@ -358,7 +370,6 @@ def call_trace(self): trace = self.trace sep = max(i['jumpDepth'] for i in trace) idx = 0 - depth = 0 for i in range(1, len(trace)): if ( trace[i]['depth'] == trace[i-1]['depth'] and @@ -372,23 +383,21 @@ def call_trace(self): def error(self, pad=3): '''Displays the source code that caused the transaction to revert.''' try: - trace = next(i for i in self.trace if i['op'] in ("REVERT", "INVALID")) + trace = next(i for i in self.trace if i['op'] in ("REVERT", "INVALID")) except StopIteration: - return + return "" span = (trace['source']['start'], trace['source']['stop']) source = open(trace['source']['filename'], encoding="utf-8").read() - newlines = [i for i in range(len(source)) if source[i]=="\n"] - start = newlines.index(next(i for i in newlines if i>=span[0])) - stop = newlines.index(next(i for i in newlines if i>=span[1])) + newlines = [i for i in range(len(source)) if source[i] == "\n"] + start = newlines.index(next(i for i in newlines if i >= span[0])) + stop = newlines.index(next(i for i in newlines if i >= span[1])) ln = start + 1 start = newlines[max(start-(pad+1), 0)] stop = newlines[min(stop+pad, len(newlines)-1)] - result = ( - ('{0[dull]}File {0[string]}"{1}"{0[dull]}, ' + - 'line {0[value]}{2}{0[dull]}, in {0[callable]}{3}').format( - color, trace['source']['filename'], ln, trace['fn'] - ) - ) + result = (( + '{0[dull]}File {0[string]}"{1}"{0[dull]}, ' + + 'line {0[value]}{2}{0[dull]}, in {0[callable]}{3}' + ).format(color, trace['source']['filename'], ln, trace['fn'])) result += ("{0[dull]}{1}{0}{2}{0[dull]}{3}{0}".format( color, source[start:span[0]], @@ -412,10 +421,10 @@ def _profile_gas(fn_name, gas_used): gas_profile.setdefault( fn_name, { - 'avg':0, - 'high':0, - 'low':float('inf'), - 'count':0 + 'avg': 0, + 'high': 0, + 'low': float('inf'), + 'count': 0 } ) gas = gas_profile[fn_name] @@ -428,6 +437,8 @@ def _profile_gas(fn_name, gas_used): _topics = {} + + def topics(): '''Generates event topics and saves them in brownie/topics.json''' if _topics: @@ -439,8 +450,9 @@ def topics(): )) except (FileNotFoundError, json.decoder.JSONDecodeError): topics = {} + _topics.update(topics) contracts = compile_contracts() - events = [x for i in contracts.values() for x in i['abi'] if x['type']=="event"] + events = [x for i in contracts.values() for x in i['abi'] if x['type'] == "event"] _topics.update(eth_event.get_event_abi(events)) json.dump( _topics, @@ -449,11 +461,3 @@ def topics(): indent=4 ) return _topics - - -def _decode_abi(value): - if type(value) is tuple: - return [_decode_abi(i) for i in value] - elif type(value) is bytes: - return "0x"+value.hex() - return value diff --git a/lib/console.py b/lib/console.py index f593e0d0a..23de2aec8 100644 --- a/lib/console.py +++ b/lib/console.py @@ -8,6 +8,7 @@ from lib.components.network import Network from lib.components.contract import _ContractBase, _ContractMethod +from lib.services.datatypes import StrictDict, KwargTuple from lib.services import color, config CONFIG = config.CONFIG @@ -38,27 +39,15 @@ def _run(self): try: readline.read_history_file("build/.history") except FileNotFoundError: - pass + open("build/.history", 'w').write("") while True: if not self._multiline: try: - cmd = input(self._prompt) + cmd = self._input(self._prompt) except KeyboardInterrupt: sys.stdout.write("\nUse exit() or Ctrl-D (i.e. EOF) to exit.\n") sys.stdout.flush() continue - except EOFError: - print() - cmd = "exit()" - if cmd == "exit()": - try: - readline.remove_history_item( - readline.get_current_history_length() - 1 - ) - except ValueError: - pass - readline.write_history_file("build/.history") - return if not cmd.strip(): continue if cmd.rstrip()[-1] == ":": @@ -66,14 +55,14 @@ def _run(self): self._prompt = "... " continue else: - try: - new_cmd = input('... ') + try: + new_cmd = self._input("... ") except KeyboardInterrupt: print() self._multiline = False self._prompt = ">>> " continue - if new_cmd: + if new_cmd: cmd += '\n' + new_cmd continue if [i for i in ['{}', '[]', '()'] if cmd.count(i[0]) > cmd.count(i[1])]: @@ -82,23 +71,30 @@ def _run(self): self._multiline = False self._prompt = "" try: - try: + try: local_['_result'] = None exec('_result = ' + cmd, self.__dict__, local_) r = local_['_result'] - if r != None: - if type(r) in (dict, config.StrictDict) and r: + if r is not None: + if type(r) in (dict, StrictDict) and r: color.pretty_dict(r) + elif type(r) in (list, tuple, KwargTuple): + color.pretty_list(r) elif type(r) is str: print(r) + elif hasattr(r, '_console_repr'): + print(r._console_repr()) else: print(repr(r)) except SyntaxError: exec(cmd, self.__dict__, local_) + except SystemExit: + return except: print(color.format_tb(sys.exc_info(), start=1)) self._prompt = ">>> " + # replaces builtin print method, for threadsafe printing def _print(self, *args, sep=' ', end='\n', file=sys.stdout, flush=False): with self._print_lock: ln = readline.get_line_buffer() @@ -107,14 +103,33 @@ def _print(self, *args, sep=' ', end='\n', file=sys.stdout, flush=False): file.write(self._prompt+ln) file.flush() + # replaces builtin dir method, for pretty and easier to read output def _dir(self, obj=None): if obj is None: obj = self - results = [(i,getattr(obj,i)) for i in builtins.dir(obj) if i[0]!="_"] + results = [(i, getattr(obj, i)) for i in builtins.dir(obj) if i[0] != "_"] print("["+"{}, ".format(color()).join( _dir_color(i[1])+i[0] for i in results )+color()+"]") + # save user input to readline history file, filter for private keys + def _input(self, prompt): + response = input(prompt) + try: + cls_, method = response[:response.index("(")].split(".") + cls_ = getattr(self, cls_) + method = getattr(cls_, method) + if hasattr(method, "_private"): + readline.replace_history_item( + readline.get_current_history_length() - 1, + response[:response.index("(")] + "()" + ) + except (ValueError, AttributeError): + pass + readline.append_history_file(1, "build/.history") + return response + + def _dir_color(obj): if type(obj).__name__ == "module": return color('module') @@ -129,13 +144,17 @@ def _dir_color(obj): return color('value') return color('callable') + def main(): - args = docopt(__doc__) + docopt(__doc__) console = Console() - network = Network(console) print("Brownie environment is ready.") - console._run() - network.save() \ No newline at end of file + try: + console._run() + except EOFError: + sys.stdout.write('\n') + finally: + network.save() diff --git a/lib/services/color.py b/lib/services/color.py index 04a0d4cff..fde5041a4 100755 --- a/lib/services/color.py +++ b/lib/services/color.py @@ -3,6 +3,7 @@ import sys import traceback +from lib.services.datatypes import StrictDict from lib.services import config CONFIG = config.CONFIG @@ -38,9 +39,12 @@ def __call__(self, color = None): if not color: return BASE+"m" color = color.split() - if len(color) == 2: - return BASE+MODIFIERS[color[0]]+COLORS[color[1]]+"m" - return BASE+COLORS[color[0]]+"m" + try: + if len(color) == 2: + return BASE+MODIFIERS[color[0]]+COLORS[color[1]]+"m" + return BASE+COLORS[color[0]]+"m" + except KeyError: + return BASE+"m" def __str__(self): return BASE+"m" @@ -48,33 +52,77 @@ def __str__(self): def __getitem__(self, color): return self(color) + # format dicts for console printing def pretty_dict(self, value, indent = 0, start=True): if start: sys.stdout.write(' '*indent+'{}{{'.format(self['dull'])) indent+=4 - for c,k in enumerate(sorted(value)): + for c,k in enumerate(sorted(value, key= lambda k: str(k))): if c: sys.stdout.write(',') sys.stdout.write('\n'+' '*indent) - if type(k) is str: sys.stdout.write("'{0[key]}{1}{0[dull]}': ".format(self, k)) else: sys.stdout.write("{0[key]}{1}{0[dull]}: ".format(self, k)) - if type(value[k]) in (dict, config.StrictDict): + if type(value[k]) in (dict, StrictDict): sys.stdout.write('{') - self.json(value[k], indent,False) + self.pretty_dict(value[k], indent,False) continue - if type(value[k]) is str: - sys.stdout.write('"{0[string]}{1}{0[dull]}"'.format(self, value[k])) - else: - sys.stdout.write('{0[value]}{1}{0[dull]}'.format(self, value[k])) + if type(value[k]) in (list, tuple): + sys.stdout.write(str(value[k])[0]) + self.pretty_list(value[k], indent, False) + continue + self._write(value[k]) indent-=4 sys.stdout.write('\n'+' '*indent+'}') if start: sys.stdout.write('\n{}'.format(self)) sys.stdout.flush() + # format lists for console printing + def pretty_list(self, value, indent = 0, start=True): + brackets = str(value)[0],str(value)[-1] + if start: + sys.stdout.write(' '*indent+'{}{}'.format(self['dull'],brackets[0])) + if value and len(value)==len([i for i in value if type(i) is dict]): + # list of dicts + sys.stdout.write('\n'+' '*(indent+4)+'{') + for c,i in enumerate(value): + if c: + sys.stdout.write(',') + self.pretty_dict(i, indent+4, False) + sys.stdout.write('\n'+ ' '*indent+brackets[1]) + elif ( + value and len(value)==len([i for i in value if type(i) is str]) and + set(len(i) for i in value) == {64} + ): + # list of bytes32 hexstrings (stack trace) + for c,i in enumerate(value): + if c: + sys.stdout.write(',') + sys.stdout.write('\n'+' '*(indent+4)) + self._write(i) + sys.stdout.write('\n'+' '*indent+brackets[1]) + else: + # all other cases + for c, i in enumerate(value): + if c: + sys.stdout.write(', ') + self._write(i) + sys.stdout.write(brackets[1]) + if start: + sys.stdout.write('\n{}'.format(self)) + sys.stdout.flush() + + def _write(self, value): + if type(value) is str: + sys.stdout.write('"{0[string]}{1}{0[dull]}"'.format(self, value)) + else: + sys.stdout.write('{0[value]}{1}{0[dull]}'.format(self, value)) + + + # format transaction related console outputs def print_colors(self, msg, key = None, value=None): if key is None: key = 'key' diff --git a/lib/services/config.py b/lib/services/config.py index 38d65cbdd..266a02fe7 100644 --- a/lib/services/config.py +++ b/lib/services/config.py @@ -4,43 +4,14 @@ import os import sys - -# dict subclass that prevents adding new keys when locked -class StrictDict(dict): - - def __init__(self, values={}): - self._locked = False - super().__init__() - self.update(values) - - def __setitem__(self, key, value): - if self._locked and key not in self: - raise KeyError("{} is not a known config setting".format(key)) - if type(value) is dict: - value = StrictDict(value) - super().__setitem__(key, value) - - def update(self,arg): - for k,v in arg.items(): - self.__setitem__(k,v) - - def _lock(self): - for v in [i for i in self.values() if type(i) is StrictDict]: - v._lock() - self._locked = True - - def _unlock(self): - for v in [i for i in self.values() if type(i) is StrictDict]: - v._unlock() - self._locked = False - +from lib.services.datatypes import StrictDict, FalseyDict # must happen in this order, color imports CONFIG CONFIG = StrictDict() from lib.services import color -def load_config(network = None): +def load_config(network=None): # set folders folder = sys.modules['__main__'].__file__ folder = folder[:folder.rfind("/")] @@ -53,7 +24,7 @@ def load_config(network = None): 'project': os.path.abspath('.') } folders = os.path.abspath('.').split('/') - for i in range(len(folders),0,-1): + for i in range(len(folders), 0, -1): folder = '/'.join(folders[:i]) if os.path.exists(folder+'/brownie-config.json'): CONFIG['folders']['project'] = folder @@ -66,12 +37,12 @@ def load_config(network = None): _recursive_update(CONFIG, local_conf) try: CONFIG['logging'] = CONFIG['logging'][sys.argv[1]] - CONFIG['logging'].setdefault('tx',0) - CONFIG['logging'].setdefault('exc',0) - for k,v in [(k,v) for k,v in CONFIG['logging'].items() if type(v) is list]: + CONFIG['logging'].setdefault('tx', 0) + CONFIG['logging'].setdefault('exc', 0) + for k, v in [(k, v) for k, v in CONFIG['logging'].items() if type(v) is list]: CONFIG['logging'][k] = v[1 if '--verbose' in sys.argv else 0] except: - CONFIG['logging'] = {"tx":1 ,"exc":1} + CONFIG['logging'] = {"tx": 1, "exc": 1} # modify network settings if not network: @@ -79,7 +50,7 @@ def load_config(network = None): try: CONFIG['active_network'] = CONFIG['networks'][network] CONFIG['active_network']['name'] = network - for key,value in CONFIG['network_defaults'].items(): + for key, value in CONFIG['network_defaults'].items(): if key not in CONFIG['active_network']: CONFIG['active_network'][key] = value if 'persist' not in CONFIG['active_network']: @@ -94,32 +65,18 @@ def load_config(network = None): CONFIG._lock() -# merges project .json with brownie .json +# merges project .json with brownie .json def _recursive_update(original, new): for k in new: if type(new[k]) is dict and k in original: _recursive_update(original[k], new[k]) - else: original[k] = new[k] - - -# dict container that returns False if key is not present -class FalseyDict: - - def __init__(self): - self._dict = {} - - def __setitem__(self, key, value): - self._dict[key] = value - - def __getitem__(self, key): - if key in self._dict: - return self._dict[key] - return False + else: + original[k] = new[k] # move argv flags into FalseyDict ARGV = FalseyDict() -for key in [i for i in sys.argv if i[:2]=="--"]: +for key in [i for i in sys.argv if i[:2] == "--"]: idx = sys.argv.index(key) if len(sys.argv) >= idx+2 and sys.argv[idx+1][:2] != "--": ARGV[key[2:]] = sys.argv[idx+1] @@ -127,8 +84,8 @@ def __getitem__(self, key): ARGV[key[2:]] = True # used to determine various behaviours in other modules -if len(sys.argv)>1: +if len(sys.argv) > 1: ARGV['mode'] = "console" if sys.argv[1] == "console" else "script" # load config -load_config(ARGV['network']) \ No newline at end of file +load_config(ARGV['network']) diff --git a/lib/services/datatypes.py b/lib/services/datatypes.py new file mode 100644 index 000000000..cf292bb3b --- /dev/null +++ b/lib/services/datatypes.py @@ -0,0 +1,98 @@ +#!/usr/bin/python3 + + +# dict subclass that prevents adding new keys when locked +class StrictDict(dict): + + def __init__(self, values={}): + self._locked = False + super().__init__() + self.update(values) + + def __setitem__(self, key, value): + if self._locked and key not in self: + raise KeyError("{} is not a known config setting".format(key)) + if type(value) is dict: + value = StrictDict(value) + super().__setitem__(key, value) + + def update(self, arg): + for k, v in arg.items(): + self.__setitem__(k, v) + + def _lock(self): + for v in [i for i in self.values() if type(i) is StrictDict]: + v._lock() + self._locked = True + + def _unlock(self): + for v in [i for i in self.values() if type(i) is StrictDict]: + v._unlock() + self._locked = False + + +# tuple/dict hybrid used for return values +class KwargTuple: + + def __init__(self, values, abi): + values = format_output(values) + self._tuple = tuple(values) + self._abi = abi + self._dict = {} + for c, i in enumerate(abi['outputs']): + if not i['name']: + continue + self._dict[i['name']] = values[c] + for i in ('count', 'index'): + setattr(self, i, getattr(self._tuple, i)) + for i in ('items', 'keys', 'values'): + setattr(self, i, getattr(self._dict, i)) + + def _console_repr(self): + return repr(self._tuple) + + def __str__(self): + return str(self._tuple) + + def __eq__(self, other): + return self._tuple == other + + def __getitem__(self, key): + if type(key) in (int, slice): + return self._tuple[key] + return self._dict[key] + + def __contains__(self, value): + return value in self._tuple + + def __iter__(self): + return iter(self._tuple) + + def __len__(self): + return len(self._tuple) + + def copy(self): + return KwargTuple(self._tuple, self._abi) + + +# dict container that returns False if key is not present +class FalseyDict: + + def __init__(self): + self._dict = {} + + def __setitem__(self, key, value): + self._dict[key] = value + + def __getitem__(self, key): + if key in self._dict: + return self._dict[key] + return False + + +def format_output(value): + if type(value) in (tuple, list): + return tuple(format_output(i) for i in value) + elif type(value) is bytes: + return "0x"+value.hex() + return value