diff --git a/CHANGELOG b/CHANGELOG index b589c9336..2b80a3e61 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,14 @@ +1.0.0b10 +-------- + + - Use pytest for unit testing + - remove check module, add check.equals comparison logic to ReturnValue + - Modify coverage evaluation to work with pytest + - remove brownie.types package, move classes to related modules + - replace wei function with Wei class, expand functionality + - add EthAddress and HexString helper classes + - improved formatting for tx.traceback and tx.call_trace + 1.0.0b9 ------- diff --git a/README.md b/README.md index 4b2065e0b..cbfb9c6e5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Brownie -[![Pypi Status](https://img.shields.io/pypi/v/eth-brownie.svg)](https://pypi.org/project/eth-brownie/) [![Build Status](https://img.shields.io/travis/com/HyperLink-Technology/brownie.svg)](https://travis-ci.com/HyperLink-Technology/brownie) [![Docs Status](https://readthedocs.org/projects/eth-brownie/badge/?version=latest)](https://eth-brownie.readthedocs.io/en/latest/) [![Coverage Status](https://coveralls.io/repos/github/HyperLink-Technology/brownie/badge.svg?branch=master)](https://coveralls.io/github/HyperLink-Technology/brownie?branch=master) +[![Pypi Status](https://img.shields.io/pypi/v/eth-brownie.svg)](https://pypi.org/project/eth-brownie/) [![Build Status](https://img.shields.io/travis/com/iamdefinitelyahuman/brownie.svg)](https://travis-ci.com/iamdefinitelyahuman/brownie) [![Docs Status](https://readthedocs.org/projects/eth-brownie/badge/?version=latest)](https://eth-brownie.readthedocs.io/en/latest/) [![Coverage Status](https://coveralls.io/repos/github/iamdefinitelyahuman/brownie/badge.svg?branch=master)](https://coveralls.io/github/iamdefinitelyahuman/brownie?branch=master) Brownie is a Python framework for deploying, testing and interacting with Ethereum smart contracts. @@ -50,15 +50,11 @@ To run the tests, first install the developer dependencies: $ pip install -r requirements-dev.txt ``` -Then use ``tox`` to run the complete suite against the full set of build targets, or ``py.test`` to run specific tests against a specific version of Python. +Then use ``tox`` to run the complete suite against the full set of build targets, or ``pytest`` to run tests against a specific version of Python. If you are using ``pytest`` you must include the ``-p no:pytest-brownie`` flag to prevent it from loading the Brownie plugin. ## Contributing -Help is always appreciated! In particular, Brownie needs work in the following areas before we can comfortably take it out of beta: - -* More tests - -Feel free to open an issue if you find a problem, or a pull request if you've solved an issue. +Help is always appreciated! Feel free to open an issue if you find a problem, or a pull request if you've solved an issue. ## License diff --git a/brownie/__init__.py b/brownie/__init__.py index f4161059a..72a9a7cf4 100644 --- a/brownie/__init__.py +++ b/brownie/__init__.py @@ -9,12 +9,12 @@ ) from .project import ( compile_source, + run, __brownie_import_all__ ) from brownie.gui import Gui -from brownie.test import check from brownie._config import CONFIG as config -from brownie.types.convert import wei +from brownie.convert import Wei __all__ = [ 'accounts', @@ -25,9 +25,9 @@ 'web3', 'project', '__brownie_import_all__', - 'check', 'compile_source', - 'wei', + 'run', + 'Wei', 'config', 'Gui' ] diff --git a/brownie/_config.py b/brownie/_config.py index b58316d03..d2736ea37 100644 --- a/brownie/_config.py +++ b/brownie/_config.py @@ -1,38 +1,58 @@ #!/usr/bin/python3 +from collections import defaultdict import json from pathlib import Path import shutil -import sys -from brownie.types.types import ( - FalseyDict, - StrictDict, - _Singleton -) +from brownie._singleton import _Singleton + REPLACE = ['active_network', 'networks'] -IGNORE = ['active_network', 'folders', 'logging'] +IGNORE = ['active_network', 'folders'] + + +class ConfigDict(dict): + '''Dict subclass that prevents adding new keys when locked''' + + 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(f"{key} is not a known config setting") + if type(value) is dict: + value = ConfigDict(value) + super().__setitem__(key, value) + + def update(self, arg): + for k, v in arg.items(): + self.__setitem__(k, v) + + def _lock(self): + '''Locks the dict so that new keys cannot be added''' + for v in [i for i in self.values() if type(i) is ConfigDict]: + v._lock() + self._locked = True + + def _unlock(self): + '''Unlocks the dict so that new keys can be added''' + for v in [i for i in self.values() if type(i) is ConfigDict]: + v._unlock() + self._locked = False def _load_default_config(): '''Loads the default configuration settings from brownie/data/config.json''' - with Path(__file__).parent.joinpath("data/config.json").open() as f: - config = _Singleton("Config", (StrictDict,), {})(json.load(f)) + with Path(__file__).parent.joinpath("data/config.json").open() as fp: + config = _Singleton("Config", (ConfigDict,), {})(json.load(fp)) config['folders'] = { 'brownie': str(Path(__file__).parent), 'project': None } config['active_network'] = {'name': None} - # set logging - 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'][k] = v[1 if '--verbose' in sys.argv else 0] - except Exception: - config['logging'] = {"tx": 1, "exc": 1} return config @@ -45,8 +65,8 @@ def load_project_config(project_path): CONFIG['folders']['project'] = str(project_path) config_path = project_path.joinpath("brownie-config.json") try: - with config_path.open() as f: - _recursive_update(CONFIG, json.load(f), []) + with config_path.open() as fp: + _recursive_update(CONFIG, json.load(fp), []) except FileNotFoundError: shutil.copy( str(Path(CONFIG['folders']['brownie']).joinpath("data/config.json")), @@ -75,7 +95,7 @@ def modify_network_config(network=None): if not CONFIG['active_network']['broadcast_reverting_tx']: print("WARNING: Reverting transactions will NOT be broadcasted.") except KeyError: - raise KeyError("Network '{}' is not defined in config.json".format(network)) + raise KeyError(f"Network '{network}' is not defined in config.json") finally: CONFIG._lock() @@ -91,13 +111,17 @@ def _recursive_update(original, new, base): original[k] = new[k] for k in [i for i in original if i not in new and not set(base+[i]).intersection(IGNORE)]: print( - "WARNING: Value '{}' not found in the config file for this project." - " The default setting has been used.".format(".".join(base+[k])) + f"WARNING: '{'.'.join(base+[k])}' not found in the config file for this project." + " The default setting has been used." ) +def update_argv_from_docopt(args): + ARGV.update(dict((k.lstrip("-"), v) for k, v in args.items())) + + # create argv object -ARGV = _Singleton("Argv", (FalseyDict,), {})() +ARGV = _Singleton("Argv", (defaultdict,), {})(lambda: None) # load config CONFIG = _load_default_config() diff --git a/brownie/_singleton.py b/brownie/_singleton.py new file mode 100644 index 000000000..4dbb1e2a3 --- /dev/null +++ b/brownie/_singleton.py @@ -0,0 +1,11 @@ +#!/usr/bin/python3 + + +class _Singleton(type): + + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(_Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/brownie/cli/__main__.py b/brownie/cli/__main__.py index c8873b4bc..49ec846d4 100644 --- a/brownie/cli/__main__.py +++ b/brownie/cli/__main__.py @@ -10,7 +10,7 @@ from brownie.exceptions import ProjectNotFound from brownie._config import ARGV -__version__ = "1.0.0b9" # did you change this in docs/conf.py as well? +__version__ = "1.0.0b10" # did you change this in docs/conf.py as well? __doc__ = """Usage: brownie [...] [options ] @@ -21,7 +21,6 @@ gui Load the GUI to view opcodes and test coverage init Initialize a new brownie project run Run a script in the /scripts folder - test Run test scripts in the /tests folder Options: --help -h Display this message diff --git a/brownie/cli/compile.py b/brownie/cli/compile.py index 1cd205bfc..7fe5d4fc1 100644 --- a/brownie/cli/compile.py +++ b/brownie/cli/compile.py @@ -25,4 +25,4 @@ def main(): if args['--all']: shutil.rmtree(build_path, ignore_errors=True) project.load(project_path) - print("Brownie project has been compiled at {}".format(build_path)) + print(f"Brownie project has been compiled at {build_path}") diff --git a/brownie/cli/console.py b/brownie/cli/console.py index 3c754435f..922840c6b 100644 --- a/brownie/cli/console.py +++ b/brownie/cli/console.py @@ -4,7 +4,7 @@ from brownie import network, project from brownie.cli.utils.console import Console -from brownie._config import ARGV, CONFIG +from brownie._config import ARGV, CONFIG, update_argv_from_docopt __doc__ = f"""Usage: brownie console [options] @@ -21,7 +21,7 @@ def main(): args = docopt(__doc__) - ARGV._update_from_args(args) + update_argv_from_docopt(args) project.load() network.connect(ARGV['network']) diff --git a/brownie/cli/run.py b/brownie/cli/run.py index cab6c50f6..063f0a651 100644 --- a/brownie/cli/run.py +++ b/brownie/cli/run.py @@ -2,32 +2,30 @@ from docopt import docopt +from brownie import network, project, run +from brownie._config import ARGV, CONFIG, update_argv_from_docopt -from brownie import network, project -from brownie.test.main import run_script -from brownie._config import ARGV, CONFIG - -__doc__ = """Usage: brownie run [] [options] +__doc__ = f"""Usage: brownie run [] [options] Arguments: The name of the script to run [] The function to call (default is main) Options: - --network [name] Use a specific network (default {}) + --network [name] Use a specific network (default {CONFIG['network_defaults']['name']}) --gas -g Display gas profile for function calls --verbose -v Enable verbose reporting --tb -t Show entire python traceback on exceptions --help -h Display this message Use run to execute scripts for contract deployment, to automate common -interactions, or for gas profiling.""".format(CONFIG['network_defaults']['name']) +interactions, or for gas profiling.""" def main(): args = docopt(__doc__) - ARGV._update_from_args(args) + update_argv_from_docopt(args) project.load() network.connect(ARGV['network']) - run_script(args[''], args[''] or "main", gas_profile=ARGV['gas']) + run(args[''], args[''] or "main", gas_profile=ARGV['gas']) diff --git a/brownie/cli/test.py b/brownie/cli/test.py deleted file mode 100644 index 898ae8f68..000000000 --- a/brownie/cli/test.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/python3 - -from docopt import docopt - -from brownie import project -from brownie.test.main import run_tests -from brownie._config import ARGV - -__doc__ = """Usage: brownie test [] [options] - -Arguments: - Only run tests from a specific file or folder - -Options: - - --update -u Only run tests where changes have occurred - --coverage -c Evaluate test coverage - --gas -g Display gas profile for function calls - --verbose -v Enable verbose reporting - --tb -t Show entire python traceback on exceptions - --help -h Display this message - -By default brownie runs every script found in the tests folder as well as any -subfolders. Files and folders beginning with an underscore will be skipped.""" - - -def main(): - args = docopt(__doc__) - ARGV._update_from_args(args) - - project.load() - run_tests( - args[''], - ARGV['update'], - ARGV['coverage'], - ARGV['gas'] - ) diff --git a/brownie/cli/utils/console.py b/brownie/cli/utils/console.py index 05da9d21a..55572ad5a 100644 --- a/brownie/cli/utils/console.py +++ b/brownie/cli/utils/console.py @@ -1,13 +1,11 @@ #!/usr/bin/python3 import atexit -import builtins import code from pathlib import Path import sys import brownie -from brownie.test.main import run_script from . import color from brownie._config import CONFIG @@ -22,10 +20,9 @@ class Console(code.InteractiveConsole): def __init__(self): locals_dict = dict((i, getattr(brownie, i)) for i in brownie.__all__) - locals_dict['run'] = run_script + locals_dict['dir'] = self._dir del locals_dict['project'] - builtins.dir = self._dir self._stdout_write = sys.stdout.write sys.stdout.write = self._console_write @@ -37,7 +34,7 @@ def __init__(self): pass super().__init__(locals_dict) - # replaces builtin dir method, for simplified and colorful output + # console dir method, for simplified and colorful output def _dir(self, obj=None): if obj is None: results = [(k, v) for k, v in self.locals.items() if not k.startswith('_')] @@ -55,7 +52,7 @@ def _console_write(self, text): text = color.pretty_dict(obj) elif obj and type(obj) in (tuple, list, set): text = color.pretty_list(obj) - except SyntaxError: + except (SyntaxError, NameError): pass return self._stdout_write(text) diff --git a/brownie/types/convert.py b/brownie/convert.py similarity index 66% rename from brownie/types/convert.py rename to brownie/convert.py index 49f93f0d0..7b17fa3c7 100644 --- a/brownie/types/convert.py +++ b/brownie/convert.py @@ -21,16 +21,88 @@ } -def _check_int_size(type_): - size = int(type_.strip("uint") or 256) - if size < 8 or size > 256 or size // 8 != size / 8: - raise ValueError(f"Invalid type: {type_}") - return size +class Wei(int): + + '''Integer subclass that converts a value to wei and allows comparison against + similarly formatted values. + + 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 + would cause inaccuracy: 8.3e32 + * bytes: b'\xff\xff' + * hex strings: "0x330124"''' + + def __new__(cls, value): + return super().__new__(cls, _to_wei(value)) + + def __hash__(self): + return super().__hash__() + + def __lt__(self, other): + return super().__lt__(_to_wei(other)) + + def __le__(self, other): + return super().__le__(_to_wei(other)) + + def __eq__(self, other): + try: + return super().__eq__(_to_wei(other)) + except TypeError: + return False + + def __ne__(self, other): + try: + return super().__ne__(_to_wei(other)) + except TypeError: + return True + + def __ge__(self, other): + return super().__ge__(_to_wei(other)) + + def __gt__(self, other): + return super().__gt__(_to_wei(other)) + + def __add__(self, other): + return Wei(super().__add__(_to_wei(other))) + + def __sub__(self, other): + return Wei(super().__sub__(_to_wei(other))) + + +def _to_wei(value): + original = value + if value is None: + return 0 + if type(value) in (bytes, HexBytes): + value = HexBytes(value).hex() + if type(value) is float and "e+" in str(value): + num, dec = str(value).split("e+") + num = num.split(".") if "." in num else [num, ""] + return int(num[0] + num[1][:int(dec)] + "0" * (int(dec) - len(num[1]))) + if type(value) is not str: + return _return_int(original, value) + if value[:2] == "0x": + return int(value, 16) + for unit, dec in UNITS.items(): + if " " + unit not in value: + continue + num = value.split(" ")[0] + num = num.split(".") if "." in num else [num, ""] + return int(num[0] + num[1][:int(dec)] + "0" * (int(dec) - len(num[1]))) + return _return_int(original, value) + + +def _return_int(original, value): + try: + return int(value) + except ValueError: + raise TypeError(f"Could not convert {type(original)} '{original}' to wei.") def to_uint(value, type_="uint256"): '''Convert a value to an unsigned integer''' - value = wei(value) + value = Wei(value) size = _check_int_size(type_) if value < 0 or value >= 2**int(size): raise OverflowError(f"{value} is outside allowable range for {type_}") @@ -39,24 +111,39 @@ def to_uint(value, type_="uint256"): def to_int(value, type_="int256"): '''Convert a value to a signed integer''' - value = wei(value) + value = Wei(value) size = _check_int_size(type_) if value < -2**int(size) // 2 or value >= 2**int(size) // 2: raise OverflowError(f"{value} is outside allowable range for {type_}") return value -def to_bool(value): - '''Convert a value to a boolean''' - if type(value) not in (int, float, bool, bytes, HexBytes, str): - raise TypeError(f"Cannot convert {type(value)} '{value}' to bool") - if type(value) in (bytes, HexBytes): - value = HexBytes(value).hex() - if type(value) is str and value[:2] == "0x": - value = int(value, 16) - if value not in (0, 1, True, False): - raise ValueError(f"Cannot convert {type(value)} '{value}' to bool") - return bool(value) +def _check_int_size(type_): + size = int(type_.strip("uint") or 256) + if size < 8 or size > 256 or size // 8 != size / 8: + raise ValueError(f"Invalid type: {type_}") + return size + + +class EthAddress(str): + + '''String subclass that raises TypeError when compared to a non-address.''' + + def __new__(cls, value): + return super().__new__(cls, to_address(value)) + + def __eq__(self, other): + return _address_compare(str(self), other) + + def __ne__(self, other): + return not _address_compare(str(self), other) + + +def _address_compare(a, b): + b = str(b) + if not b.startswith('0x') or not eth_utils.is_hex(b) or len(b) != 42: + raise TypeError(f"Invalid type for comparison: '{b}' is not a valid address") + return a.lower() == b.lower() def to_address(value): @@ -67,12 +154,35 @@ def to_address(value): try: return eth_utils.to_checksum_address(value) except ValueError: - raise ValueError(f"'{value}' is not a valid ETH address.") + raise ValueError(f"'{value}' is not a valid ETH address.") from None + + +class HexString(str): + + '''String subclass for hexstring comparisons. Raises TypeError if compared to + a non-hexstring. Evaluates True for hexstrings with the same value but differing + leading zeros or capitalization.''' + + def __new__(cls, value): + return super().__new__(cls, bytes_to_hex(value)) + + def __eq__(self, other): + return _hex_compare(self, other) + + def __ne__(self, other): + return not _hex_compare(self, other) + + +def _hex_compare(a, b): + b = str(b) + if not b.startswith('0x') or not eth_utils.is_hex(b): + raise TypeError(f"Invalid type for comparison: '{b}' is not a valid hex string") + return a.lstrip('0x').lower() == b.lstrip('0x').lower() def to_bytes(value, type_="bytes32"): '''Convert a value to bytes''' - if type(value) not in (HexBytes, bytes, str, int): + if type(value) not in (HexBytes, HexString, bytes, str, int): raise TypeError(f"'{value}', type {type(value)}, cannot convert to {type_}") if type_ == "byte": type_ = "bytes1" @@ -91,6 +201,32 @@ def to_bytes(value, type_="bytes32"): raise OverflowError(f"'{value}' exceeds maximum length for {type_}") +def bytes_to_hex(value): + '''Convert a bytes value to a hexstring''' + if type(value) not in (bytes, HexBytes, HexString, str, int): + raise TypeError(f"Cannot convert {type(value)} '{value}' from bytes to hex.") + if type(value) in (bytes, HexBytes): + value = HexBytes(value).hex() + if type(value) is int: + value = hex(value) + if not eth_utils.is_hex(value): + raise ValueError(f"'{value}' is not a valid hex string") + return eth_utils.add_0x_prefix(value) + + +def to_bool(value): + '''Convert a value to a boolean''' + if type(value) not in (int, float, bool, bytes, HexBytes, str): + raise TypeError(f"Cannot convert {type(value)} '{value}' to bool") + if type(value) in (bytes, HexBytes): + value = HexBytes(value).hex() + if type(value) is str and value[:2] == "0x": + value = int(value, 16) + if value not in (0, 1, True, False): + raise ValueError(f"Cannot convert {type(value)} '{value}' to bool") + return bool(value) + + def to_string(value): '''Convert a value to a string''' if type(value) in (bytes, HexBytes): @@ -104,53 +240,6 @@ def to_string(value): return value -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 - would cause inaccuracy: 8.3e32 - * bytes: b'\xff\xff' - * hex strings: "0x330124"''' - original = value - if value is None: - return 0 - if type(value) in (bytes, HexBytes): - value = HexBytes(value).hex() - if type(value) is float and "e+" in str(value): - num, dec = str(value).split("e+") - num = num.split(".") if "." in num else [num, ""] - return int(num[0] + num[1][:int(dec)] + "0" * (int(dec) - len(num[1]))) - if type(value) is not str: - return int(value) - if value[:2] == "0x": - return int(value, 16) - for unit, dec in UNITS.items(): - if " " + unit not in value: - continue - num = value.split(" ")[0] - num = num.split(".") if "." in num else [num, ""] - return int(num[0] + num[1][:int(dec)] + "0" * (int(dec) - len(num[1]))) - try: - return int(value) - except ValueError: - raise TypeError(f"Could not convert {type(original)} '{original}' to wei.") - - -def bytes_to_hex(value): - '''Convert a bytes value to a hexstring''' - if type(value) not in (bytes, HexBytes, str, int): - raise TypeError(f"Cannot convert {type(value)} '{value}' from bytes to hex.") - if type(value) in (bytes, HexBytes): - value = HexBytes(value).hex() - if type(value) is int: - value = hex(value) - if not eth_utils.is_hex(value): - raise ValueError(f"'{value}' is not a valid hex string") - return eth_utils.add_0x_prefix(value) - - def format_input(abi, inputs): '''Format contract inputs based on ABI types. @@ -173,6 +262,23 @@ def format_output(abi, outputs): return _format(abi, "outputs", outputs) +def format_event(event): + '''Format event data. + + Args: + event: decoded event as given by eth_event.decode_logs or eth_event.decode_trace + + Mutates the event in place and returns it.''' + + for e in [i for i in event['data'] if not i['decoded']]: + e['type'] = "bytes32" + e['name'] += " (indexed)" + values = _format(event, 'data', [i['value'] for i in event['data']]) + for i in range(len(event['data'])): + event['data'][i]['value'] = values[i] + return event + + def _format(abi, key, values): try: name = abi['name'] @@ -209,17 +315,22 @@ def _format(abi, key, values): values[i] = to_uint(values[i], type_) elif "int" in type_: values[i] = to_int(values[i], type_) - elif "bool" in type_: + elif type_ == "bool": values[i] = to_bool(values[i]) - elif "address" in type_: - values[i] = to_address(values[i]) + elif type_ == "address": + if key == "inputs": + values[i] = to_address(values[i]) + else: + values[i] = EthAddress(values[i]) elif "byte" in type_: if key == "inputs": values[i] = to_bytes(values[i], type_) else: - values[i] = bytes_to_hex(values[i]) + values[i] = HexString(values[i]) elif "string" in type_: values[i] = to_string(values[i]) + else: + raise TypeError(f"Unknown type: {type_}") except Exception as e: raise type(e)(f"{name} argument #{i}: '{values[i]}' - {e}") return tuple(values) diff --git a/brownie/data/config.json b/brownie/data/config.json index d8956fccb..73be9cecd 100644 --- a/brownie/data/config.json +++ b/brownie/data/config.json @@ -18,7 +18,8 @@ "test": { "gas_limit": 6721975, "default_contract_owner": false, - "broadcast_reverting_tx": true + "broadcast_reverting_tx": true, + "revert_traceback": false }, "solc": { "version": "0.5.7", @@ -26,25 +27,6 @@ "runs": 200, "minify_source": false }, - "logging": { - "test": { - "tx": 0, - "exc": [1, 2] - - }, - "run": { - "tx": [1, 2], - "exc": 2 - }, - "console": { - "tx": 1, - "exc": 2 - }, - "coverage": { - "tx": 0, - "exc": [1, 2] - } - }, "colors": { "key": "", "value": "bright blue", diff --git a/brownie/exceptions.py b/brownie/exceptions.py index 7cf312574..de029c9aa 100644 --- a/brownie/exceptions.py +++ b/brownie/exceptions.py @@ -20,9 +20,8 @@ def __init__(self, msg, cmd, proc, uri): out = proc.stdout.read().decode().strip() or " (Empty)" err = proc.stderr.read().decode().strip() or " (Empty)" super().__init__( - "{}\n\nCommand: {}\nURI: {}\nExit Code: {}\n\nStdout:\n{}\n\nStderr:\n{}".format( - msg, cmd, uri, code, out, err - ) + f"{msg}\n\nCommand: {cmd}\nURI: {uri}\nExit Code: {code}" + f"\n\nStdout:\n{out}\n\nStderr:\n{err}" ) diff --git a/brownie/gui/listview.py b/brownie/gui/listview.py index ed03ef07c..dddf2cd74 100755 --- a/brownie/gui/listview.py +++ b/brownie/gui/listview.py @@ -73,7 +73,7 @@ def set_opcodes(self, pcMap): ): tag = "NoSource" else: - tag = "{0[path]}:{0[offset][0]}:{0[offset][1]}".format(op) + tag = f"{op['path']}:{op['offset'][0]}:{op['offset'][1]}" self.insert([str(pc), op['op']], [tag, op['op']]) def _select_bind(self, event): @@ -94,9 +94,9 @@ def _select_bind(self, event): if 'value' in pcMap: console_str += " "+pcMap['value'] if pcMap['op'] == "PUSH2": - console_str += " ({})".format(int(pcMap['value'], 16)) + console_str += f" ({int(pcMap['value'], 16)})" if 'offset' in pcMap: - console_str += " {}".format(tuple(pcMap['offset'])) + console_str += f" {tuple(pcMap['offset'])}" self.root.main.console.insert(1.0, console_str) self.root.main.console.configure(state="disabled") diff --git a/brownie/gui/select.py b/brownie/gui/select.py index 12ed9e60f..f5c1c50ec 100755 --- a/brownie/gui/select.py +++ b/brownie/gui/select.py @@ -35,8 +35,8 @@ def __init__(self, parent, report_paths): self._reports = {} for path in report_paths: try: - with path.open() as f: - self._reports[path.stem] = json.load(f) + with path.open() as fp: + self._reports[path.stem] = json.load(fp) except Exception: continue super().__init__( diff --git a/brownie/gui/textbook.py b/brownie/gui/textbook.py index 1de85bc52..14ba57fab 100755 --- a/brownie/gui/textbook.py +++ b/brownie/gui/textbook.py @@ -32,9 +32,9 @@ def add(self, path): label = path.name if label in [i._label for i in self._frames]: return - with path.open() as f: - frame = TextBox(self, f.read()) - super().add(frame, text=" {} ".format(label)) + with path.open() as fp: + frame = TextBox(self, fp.read()) + super().add(frame, text=f" {label} ") frame._id = len(self._frames) frame._label = label frame._visible = True @@ -57,7 +57,7 @@ def show(self, label): if frame._visible: return frame._visible = True - super().add(frame, text=" {} ".format(label)) + super().add(frame, text=f" {label} ") def set_visible(self, labels): labels = [Path(i).name for i in labels] @@ -215,7 +215,7 @@ def _offset_to_coord(self, value): text = self._text.get(1.0, "end") line = text[:value].count('\n') + 1 offset = len(text[:value].split('\n')[-1]) - return "{}.{}".format(line, offset) + return f"{line}.{offset}" def _coord_to_offset(self, value): row, col = [int(i) for i in value.split('.')] diff --git a/brownie/gui/tooltip.py b/brownie/gui/tooltip.py index e4a974e1c..0bf968cc0 100755 --- a/brownie/gui/tooltip.py +++ b/brownie/gui/tooltip.py @@ -26,10 +26,7 @@ def enter(self, event): def show(self): if self.kill: return - self.geometry("+{}+{}".format( - self.winfo_pointerx()+5, - self.winfo_pointery()+5 - )) + self.geometry(f"+{self.winfo_pointerx()+5}+{self.winfo_pointery()+5}") self.lift() self.deiconify() diff --git a/brownie/network/account.py b/brownie/network/account.py index 858be7e00..291a6ff36 100644 --- a/brownie/network/account.py +++ b/brownie/network/account.py @@ -14,8 +14,8 @@ from brownie.network.transaction import TransactionReceipt from .rpc import Rpc from .web3 import Web3 -from brownie.types.convert import to_address, wei -from brownie.types.types import _Singleton +from brownie.convert import to_address, Wei +from brownie._singleton import _Singleton from brownie._config import CONFIG web3 = Web3() @@ -30,6 +30,7 @@ def __init__(self): # prevent private keys from being stored in read history self.add.__dict__['_private'] = True Rpc()._objects.append(self) + self._reset() def _reset(self): self._accounts.clear() @@ -102,9 +103,9 @@ def load(self, filename=None): json_file = project_path.joinpath(filename) if not json_file.exists(): raise FileNotFoundError(f"Cannot find {json_file}") - with json_file.open() as f: + with json_file.open() as fp: priv_key = web3.eth.account.decrypt( - json.load(f), + json.load(fp), getpass("Enter the password for this account: ") ) return self.add(priv_key) @@ -184,11 +185,12 @@ def _check_for_revert(self, tx): try: web3.eth.call(dict((k, v) for k, v in tx.items() if v)) except ValueError as e: - raise VirtualMachineError(e) + raise VirtualMachineError(e) from None def balance(self): '''Returns the current balance at the address, in wei.''' - return web3.eth.getBalance(self.address) + balance = web3.eth.getBalance(self.address) + return Wei(balance) def deploy(self, contract, *args, amount=None, gas_limit=None, gas_price=None, callback=None): '''Deploys a contract. @@ -211,10 +213,10 @@ def deploy(self, contract, *args, amount=None, gas_limit=None, gas_price=None, c try: txid = self._transact({ 'from': self.address, - 'value': wei(amount), + 'value': Wei(amount), 'nonce': self.nonce, - 'gasPrice': wei(gas_price) or self._gas_price(), - 'gas': wei(gas_limit) or self._gas_limit("", amount, data), + 'gasPrice': Wei(gas_price) or self._gas_price(), + 'gas': Wei(gas_limit) or self._gas_limit("", amount, data), 'data': HexBytes(data) }) revert = None @@ -247,7 +249,7 @@ def estimate_gas(self, to, amount, data=""): return web3.eth.estimateGas({ 'from': self.address, 'to': str(to), - 'value': wei(amount), + 'value': Wei(amount), 'data': HexBytes(data) }) @@ -269,10 +271,10 @@ def transfer(self, to, amount, gas_limit=None, gas_price=None, data=""): txid = self._transact({ 'from': self.address, 'to': str(to), - 'value': wei(amount), + 'value': Wei(amount), 'nonce': self.nonce, - 'gasPrice': wei(gas_price) if gas_price is not None else self._gas_price(), - 'gas': wei(gas_limit) or self._gas_limit(to, amount, data), + 'gasPrice': Wei(gas_price) if gas_price is not None else self._gas_price(), + 'gas': Wei(gas_limit) or self._gas_limit(to, amount, data), 'data': HexBytes(data) }) revert = None @@ -342,8 +344,8 @@ def save(self, filename, overwrite=False): self.private_key, getpass("Enter the password to encrypt this account with: ") ) - with json_file.open('w') as f: - json.dump(encrypted, f) + with json_file.open('w') as fp: + json.dump(encrypted, fp) return str(json_file) def _transact(self, tx): @@ -363,4 +365,4 @@ def _raise_or_return_tx(exc): except SyntaxError: raise exc except Exception: - raise VirtualMachineError(exc) + raise VirtualMachineError(exc) from None diff --git a/brownie/network/contract.py b/brownie/network/contract.py index b94d976ee..f4807163e 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -11,8 +11,8 @@ from .history import _ContractHistory from .rpc import Rpc from .web3 import Web3 -from brownie.types import KwargTuple -from brownie.types.convert import format_input, format_output, to_address +from .return_value import ReturnValue +from brownie.convert import format_input, format_output, to_address, Wei from brownie.exceptions import UndeployedLibrary, VirtualMachineError from brownie._config import ARGV, CONFIG @@ -229,7 +229,8 @@ def __eq__(self, other): def balance(self): '''Returns the current ether balance of the contract, in wei.''' - return web3.eth.getBalance(self.address) + balance = web3.eth.getBalance(self.address) + return Wei(balance) class OverloadedMethod: @@ -284,10 +285,10 @@ def call(self, *args): try: data = web3.eth.call(dict((k, v) for k, v in tx.items() if v)) except ValueError as e: - raise VirtualMachineError(e) + raise VirtualMachineError(e) from None return self.decode_abi(data) - def transact(self, *args, _rpc_clear=True): + def transact(self, *args): '''Broadcasts a transaction that calls this contract method. Args: @@ -302,8 +303,6 @@ def transact(self, *args, _rpc_clear=True): "No deployer address given. You must supply a tx dict" " with a 'from' field as the last argument." ) - if _rpc_clear: - rpc._internal_clear() return tx['from'].transfer( self._address, tx['value'], @@ -335,7 +334,7 @@ def decode_abi(self, hexstr): result = format_output(self.abi, result) if len(result) == 1: return result[0] - return KwargTuple(result, self.abi) + return ReturnValue(result, self.abi) class ContractTx(_ContractMethod): @@ -380,15 +379,16 @@ def __call__(self, *args): Returns: Contract method return value(s).''' - if ARGV['always_transact']: - rpc._internal_snap() - args, tx = _get_tx(self._owner, args) - tx['gas_price'] = 0 - tx = self.transact(*args, tx, _rpc_clear=False) - if tx.modified_state: - rpc._internal_revert() + if not ARGV['always_transact']: + return self.call(*args) + rpc._internal_snap() + args, tx = _get_tx(self._owner, args) + tx['gas_price'] = 0 + try: + tx = self.transact(*args, tx) return tx.return_value - return self.call(*args) + finally: + rpc._internal_revert() def _get_tx(owner, args): diff --git a/brownie/network/event.py b/brownie/network/event.py index fd461e86c..8cd4711b3 100644 --- a/brownie/network/event.py +++ b/brownie/network/event.py @@ -5,10 +5,134 @@ import eth_event -from brownie.types import EventDict +from brownie.convert import format_event from brownie._config import CONFIG +class EventDict: + '''Dict/list hybrid container, base class for all events fired in a transaction.''' + + def __init__(self, events): + '''Instantiates the class. + + Args: + events: event data as supplied by eth_event.decode_logs or eth_event.decode_trace''' + self._ordered = [_EventItem( + i['name'], + [dict((x['name'], x['value']) for x in i['data'])], + (pos,) + ) for pos, i in enumerate(events)] + self._dict = {} + for event in self._ordered: + if event.name in self._dict: + continue + events = [i for i in self._ordered if i.name == event.name] + self._dict[event.name] = _EventItem( + event.name, + events, + tuple(i.pos[0] for i in events) + ) + + def __repr__(self): + return str(self) + + def __bool__(self): + return bool(self._ordered) + + def __contains__(self, name): + '''returns True if an event fired with the given name.''' + return name in [i.name for i in self._ordered] + + def __getitem__(self, key): + '''if key is int: returns the n'th event that was fired + if key is str: returns a _EventItem dict of all events where name == key''' + if type(key) is int: + return self._ordered[key] + return self._dict[key] + + def __iter__(self): + return iter(self._ordered) + + def __len__(self): + '''returns the number of events that fired.''' + return len(self._ordered) + + def __str__(self): + return str(dict((k, [i[0] for i in v._ordered]) for k, v in self._dict.items())) + + def count(self, name): + '''EventDict.count(name) -> integer -- return number of occurrences of name''' + return len([i.name for i in self._ordered if i.name == name]) + + def items(self): + '''EventDict.items() -> a set-like object providing a view on EventDict's items''' + return self._dict.items() + + def keys(self): + '''EventDict.keys() -> a set-like object providing a view on EventDict's keys''' + return self._dict.keys() + + def values(self): + '''EventDict.values() -> an object providing a view on EventDict's values''' + return self._dict.values() + + +class _EventItem: + '''Dict/list hybrid container, represents one or more events with the same name + that were fired in a transaction. + + Attributes: + name: event name + pos: tuple of indexes where this event fired''' + + def __init__(self, name, event_data, pos): + self.name = name + self._ordered = event_data + self.pos = pos + + def __getitem__(self, key): + '''if key is int: returns the n'th event that was fired with this name + if key is str: returns the value of data field 'key' from the 1st event + within the container ''' + if type(key) is int: + return self._ordered[key] + return self._ordered[0][key] + + def __contains__(self, name): + '''returns True if this event contains a value with the given name.''' + return name in self._ordered[0] + + def __len__(self): + '''returns the number of events held in this container.''' + return len(self._ordered) + + def __repr__(self): + return str(self) + + def __str__(self): + if len(self._ordered) == 1: + return str(self._ordered[0]) + return str([i[0] for i in self._ordered]) + + def __iter__(self): + return iter(self._ordered) + + def __eq__(self, other): + return other == self._ordered + + def items(self): + '''_EventItem.items() -> a set-like object providing a view on _EventItem[0]'s items''' + return self._ordered[0].items() + + def keys(self): + '''_EventItem.keys() -> a set-like object providing a view on _EventItem[0]'s keys''' + return self._ordered[0].keys() + + def values(self): + '''_EventItem.values() -> an object providing a view on _EventItem[0]'s values''' + return self._ordered[0].values() + + def _get_path(): return Path(CONFIG['folders']['brownie']).joinpath('data/topics.json') @@ -18,27 +142,29 @@ def get_topics(abi): new_topics.update(eth_event.get_event_abi(abi)) if new_topics != _topics: _topics.update(new_topics) - with _get_path().open('w') as f: - json.dump(new_topics, f, sort_keys=True, indent=2) + with _get_path().open('w') as fp: + json.dump(new_topics, fp, sort_keys=True, indent=2) return eth_event.get_topics(abi) def decode_logs(logs): - try: - return EventDict(eth_event.decode_logs(logs, _topics)) - except Exception: + if not logs: return [] + events = eth_event.decode_logs(logs, _topics) + events = [format_event(i) for i in events] + return EventDict(events) def decode_trace(trace): - try: - return EventDict(eth_event.decode_trace(trace, _topics)) - except Exception: + if not trace: return [] + events = eth_event.decode_trace(trace, _topics) + events = [format_event(i) for i in events] + return EventDict(events) try: - with _get_path().open() as f: - _topics = json.load(f) + with _get_path().open() as fp: + _topics = json.load(fp) except (FileNotFoundError, json.decoder.JSONDecodeError): _topics = {} diff --git a/brownie/network/history.py b/brownie/network/history.py index d71a15c66..4cee94ee8 100644 --- a/brownie/network/history.py +++ b/brownie/network/history.py @@ -3,9 +3,10 @@ from collections import OrderedDict from .rpc import Rpc -from brownie.types.types import _Singleton -from brownie.types.convert import to_address from .web3 import Web3 +from brownie.convert import to_address +from brownie._singleton import _Singleton + web3 = Web3() @@ -124,3 +125,9 @@ def find(self, address): address = to_address(address) contracts = [x for v in self._dict.values() for x in v.values()] return next((i for i in contracts if i == address), None) + + def dependencies(self): + dependencies = set(k for k, v in self._dict.items() if v) + for i in dependencies.copy(): + dependencies.update(list(self._dict[i].values())[0]._build['dependencies']) + return sorted(dependencies) diff --git a/brownie/network/main.py b/brownie/network/main.py index e9f9abcc0..3236d4aff 100644 --- a/brownie/network/main.py +++ b/brownie/network/main.py @@ -2,6 +2,7 @@ from .web3 import Web3 from .rpc import Rpc +from .account import Accounts from brownie._config import CONFIG, modify_network_config rpc = Rpc() @@ -31,6 +32,8 @@ def connect(network=None): rpc.attach(CONFIG['active_network']['host']) else: rpc.launch(CONFIG['active_network']['test-rpc']) + else: + Accounts()._reset() except Exception: CONFIG['active_network'] = {'name': None} web3.disconnect() diff --git a/brownie/network/return_value.py b/brownie/network/return_value.py new file mode 100644 index 000000000..97ad89653 --- /dev/null +++ b/brownie/network/return_value.py @@ -0,0 +1,113 @@ +#!/usr/bin/python3 + +from copy import deepcopy + +from brownie.convert import EthAddress, HexString, Wei + + +class ReturnValue: + '''Tuple/dict hybrid container, used for return values on callable functions''' + + def __init__(self, values, abi): + 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] + + def __repr__(self): + return repr(self._tuple) + + def __str__(self): + return str(self._tuple) + + def __eq__(self, other): + return _kwargtuple_compare(self, other) + + def __getitem__(self, key): + if type(key) is slice: + abi = deepcopy(self._abi) + abi['outputs'] = abi['outputs'][key] + return ReturnValue(self._tuple[key], abi) + if type(key) is int: + return self._tuple[key] + return self._dict[key] + + def __contains__(self, value): + return self.count(value) > 0 + + def __iter__(self): + return iter(self._tuple) + + def __len__(self): + return len(self._tuple) + + def copy(self): + '''ReturnValue.copy() -> a shallow copy of ReturnValue''' + return ReturnValue(self._tuple, self._abi) + + def count(self, value): + '''ReturnValue.count(value) -> integer -- return number of occurrences of value''' + count = 0 + for item in self: + try: + if _kwargtuple_compare(item, value): + count += 1 + except TypeError: + continue + return count + + def dict(self): + '''ReturnValue.dict() -> a dictionary of ReturnValue's named items''' + return self._dict + + def index(self, value, start=0, stop=None): + '''ReturnValue.index(value, [start, [stop]]) -> integer -- return first index of value. + Raises ValueError if the value is not present.''' + if stop is None: + stop = len(self) + for i in range(start, stop): + try: + if _kwargtuple_compare(self._tuple[i], value): + return i + except TypeError: + continue + raise ValueError(f"{value} is not in ReturnValue") + + def items(self): + '''ReturnValue.items() -> a set-like object providing a view on ReturnValue's named items''' + return self._dict.items() + + def keys(self): + '''ReturnValue.keys() -> a set-like object providing a view on ReturnValue's keys''' + return self._dict.keys() + + +def _kwargtuple_compare(a, b): + if type(a) not in (tuple, list, ReturnValue): + types_ = set([type(a), type(b)]) + if types_.intersection([bool, type(None)]): + return a is b + if types_.intersection([dict, EthAddress, HexString]): + return a == b + return _convert_str(a) == _convert_str(b) + if type(b) not in (tuple, list, ReturnValue) or len(b) != len(a): + return False + return next((False for i in range(len(a)) if not _kwargtuple_compare(a[i], b[i])), True) + + +def _convert_str(value): + if type(value) is not str: + if not hasattr(value, "address"): + return value + value = value.address + if value.startswith("0x"): + return "0x" + value.lstrip("0x").lower() + if value.count(" ") != 1: + return value + try: + return Wei(value) + except ValueError: + return value diff --git a/brownie/network/rpc.py b/brownie/network/rpc.py index b5595fb43..33c7842d5 100644 --- a/brownie/network/rpc.py +++ b/brownie/network/rpc.py @@ -8,8 +8,12 @@ from .web3 import Web3 -from brownie.types.types import _Singleton -from brownie.exceptions import RPCProcessError, RPCConnectionError, RPCRequestError +from brownie._singleton import _Singleton +from brownie.exceptions import ( + RPCProcessError, + RPCConnectionError, + RPCRequestError +) web3 = Web3() @@ -225,11 +229,7 @@ def reset(self): return "Block height reset to 0" def _internal_snap(self): - if not self._internal_id: - self._internal_id = self._snap() - - def _internal_clear(self): - self._internal_id = None + self._internal_id = self._snap() def _internal_revert(self): self._request("evm_revert", [self._internal_id]) diff --git a/brownie/network/transaction.py b/brownie/network/transaction.py index 591963a90..cd3d8cd10 100644 --- a/brownie/network/transaction.py +++ b/brownie/network/transaction.py @@ -1,5 +1,7 @@ #!/usr/bin/python3 +from hashlib import sha1 +import requests import threading import time @@ -15,10 +17,12 @@ decode_trace ) from .web3 import Web3 +from brownie.convert import Wei from brownie.cli.utils import color from brownie.exceptions import RPCRequestError, VirtualMachineError from brownie.project import build, sources -from brownie._config import ARGV, CONFIG +from brownie.test import coverage +from brownie._config import ARGV history = TxHistory() _contracts = _ContractHistory() @@ -73,7 +77,7 @@ def __init__(self, txid, sender=None, silent=False, name='', callback=None, reve ''' if type(txid) is not str: txid = txid.hex() - if CONFIG['logging']['tx'] and not silent: + if not silent: print(f"\n{color['key']}Transaction sent{color}: {color['value']}{txid}{color}") history._add_tx(self) @@ -122,17 +126,18 @@ def __init__(self, txid, sender=None, silent=False, name='', callback=None, reve confirm_thread.join() if ARGV['cli'] == "console": return - # if coverage evaluation is active, get the trace immediately - if ARGV['coverage']: + # if coverage evaluation is active, evaluate the trace + if ARGV['coverage'] and not coverage.add_from_cached(self.coverage_hash) and self.trace: self._expand_trace() if not self.status: if revert[0] is None: # no revert message and unable to check dev string - have to get trace self._expand_trace() - raise VirtualMachineError({ - "message": f"{revert[2]} {self.revert_msg or ''}", - "source": self._error_string(1) - }) + # raise from a new function to reduce pytest traceback length + _raise( + f"{revert[2]} {self.revert_msg or ''}", + self._traceback_string() if ARGV['revert'] else self._error_string(1) + ) except KeyboardInterrupt: if ARGV['cli'] != "console": raise @@ -166,13 +171,13 @@ def _await_confirmation(self, silent, callback): time.sleep(0.5) self._set_from_tx(tx) - if not tx['blockNumber'] and CONFIG['logging']['tx'] and not silent: + if not tx['blockNumber'] and not silent: print("Waiting for confirmation...") # await confirmation receipt = web3.eth.waitForTransactionReceipt(self.txid, None) self._set_from_receipt(receipt) - if not silent and CONFIG['logging']['tx']: + if not silent: print(self._confirm_output()) if callback: callback(self) @@ -181,7 +186,7 @@ def _set_from_tx(self, tx): if not self.sender: self.sender = tx['from'] self.receiver = tx['to'] - self.value = tx['value'] + self.value = Wei(tx['value']) self.gas_price = tx['gasPrice'] self.gas_limit = tx['gas'] self.input = tx['input'] @@ -203,14 +208,18 @@ def _set_from_receipt(self, receipt): self.logs = receipt['logs'] self.status = receipt['status'] + base = ( + f"{self.nonce}{self.block_number}{self.sender}{self.receiver}" + f"{self.value}{self.input}{self.status}{self.gas_used}{self.txindex}" + ) + self.coverage_hash = sha1(base.encode()).hexdigest() + if self.status: self.events = decode_logs(receipt['logs']) if self.fn_name: history._gas(self._full_name(), receipt['gasUsed']) def _confirm_output(self): - if CONFIG['logging']['tx'] >= 2: - return self.info() status = "" if not self.status: status = f"({color['error']}{self.revert_msg or 'reverted'}{color}) " @@ -244,7 +253,19 @@ def _get_trace(self): self.trace = [] return - trace = web3.providers[0].make_request('debug_traceTransaction', [self.txid, {}]) + try: + trace = web3.providers[0].make_request( + 'debug_traceTransaction', + (self.txid, {'disableStorage': ARGV['cli'] != "console"}) + ) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + msg = f"Encountered a {type(e).__name__} while requesting " + msg += "debug_traceTransaction. The local RPC client has likely crashed." + if ARGV['coverage']: + msg += " If the error persists, import brownie.test.skipcoverage" + msg += " and apply @skipcoverage to this test." + raise RPCRequestError(msg) from None + if 'error' in trace: self.modified_state = None raise RPCRequestError(trace['error']['message']) @@ -317,6 +338,7 @@ def _expand_trace(self): self._get_trace() self.trace = trace = self._trace if not trace or 'fn' in trace[0]: + coverage.add(self.coverage_hash, {}) return # last_map gives a quick reference of previous values at each depth @@ -329,6 +351,9 @@ def _expand_trace(self): 'pc_map': self.receiver._build['pcMap'] }} + coverage_eval = {self.receiver._name: {}} + active_branches = set() + for i in range(len(trace)): # if depth has increased, tx has called into a different contract if trace[i]['depth'] > trace[i-1]['depth']: @@ -350,6 +375,8 @@ def _expand_trace(self): 'jumpDepth': 0, 'pc_map': contract._build['pcMap'] } + if contract._name not in coverage_eval: + coverage_eval[contract._name] = {} # update trace from last_map last = last_map[trace[i]['depth']] @@ -361,11 +388,31 @@ def _expand_trace(self): 'source': False }) pc = last['pc_map'][trace[i]['pc']] - if 'path' in pc: - trace[i]['source'] = {'filename': pc['path'], 'offset': pc['offset']} + if 'path' not in pc: + continue + + trace[i]['source'] = {'filename': pc['path'], 'offset': pc['offset']} + + if 'fn' not in pc: + continue + + # calculate coverage + if ' 0: del last['fn'][-1] last['jumpDepth'] -= 1 + coverage.add(self.coverage_hash, dict((k, v) for k, v in coverage_eval.items() if v)) def _full_name(self): if self.contract_name: @@ -438,8 +486,9 @@ def call_trace(self): raise NotImplementedError("Call trace is not available for deployment transactions.") result = f"Call trace for '{color['value']}{self.txid}{color}':" - result += _step_print(trace[0], trace[-1], 0, 0, len(trace)) + result += _step_print(trace[0], trace[-1], None, 0, len(trace)) indent = {0: 0} + indent_chars = [""]*1000 # (index, depth, jumpDepth) for relevent steps in the trace trace_index = [(0, 0, 0)] + [ @@ -451,9 +500,12 @@ def call_trace(self): last = trace_index[i-1] if depth > last[1]: # called to a new contract - indent[depth] = trace[idx-1]['jumpDepth'] + indent[depth-1] + indent[depth] = trace_index[i-1][2] + indent[depth-1] end = next((x[0] for x in trace_index[i+1:] if x[1] < depth), len(trace)) - result += _step_print(trace[idx], trace[end-1], depth+indent[depth], idx, end) + _depth = depth+indent[depth] + symbol, indent_chars[_depth] = _check_last(trace_index[i-1:]) + indent_str = "".join(indent_chars[:_depth])+symbol + result += _step_print(trace[idx], trace[end-1], indent_str, idx, end) elif depth == last[1] and jump_depth > last[2]: # jumped into an internal function end = next(( @@ -461,24 +513,29 @@ def call_trace(self): (x[1] == depth and x[2] < jump_depth) ), len(trace)) _depth = depth+jump_depth+indent[depth] - result += _step_print(trace[idx], trace[end-1], _depth, idx, end) + symbol, indent_chars[_depth] = _check_last(trace_index[i-1:]) + indent_str = "".join(indent_chars[:_depth])+symbol + result += _step_print(trace[idx], trace[end-1], indent_str, idx, end) print(result) def traceback(self): + print(self._traceback_string()) + + def _traceback_string(self): '''Returns an error traceback for the transaction.''' if self.status == 1: - return + return "" trace = self.trace if not trace: if not self.contract_address: - return + return "" raise NotImplementedError("Traceback is not available for deployment transactions.") try: - idx = next(i for i in range(len(trace)) if trace[i]['op'] in {"REVERT", "INVALID"}) + idx = next(i for i in range(len(trace)) if trace[i]['op'] in ("REVERT", "INVALID")) trace_range = range(idx, -1, -1) except StopIteration: - return + return "" result = [next(i for i in trace_range if trace[i]['source'])] depth, jump_depth = trace[idx]['depth'], trace[idx]['jumpDepth'] @@ -493,8 +550,8 @@ def traceback(self): depth, jump_depth = trace[idx]['depth'], trace[idx]['jumpDepth'] except StopIteration: break - print( - f"Traceback for '{color['value']}{self.txid}{color}':\n" + + return ( + f"{color}Traceback for '{color['value']}{self.txid}{color}':\n" + "\n".join(self._source_string(i, 0) for i in result[::-1]) ) @@ -545,16 +602,20 @@ def _source_string(self, idx, pad): if not source: return "" source = sources.get_highlighted_source(source['filename'], source['offset'], pad) + if not source: + return "" return _format_source(source, self.trace[idx]['pc'], idx, self.trace[idx]['fn']) def _format_source(source, pc, idx, fn_name): + ln = f" {color['value']}{source[2][0]}" + if source[2][1] > source[2][0]: + ln = f"s{ln}{color['dull']}-{color['value']}{source[2][1]}" return ( f"{color['dull']}Trace step {color['value']}{idx}{color['dull']}, " - f"program counter {color['value']}{pc}{color['dull']}:" - f"\n File {color['string']}\"{source[1]}\"{color['dull']}, " - f"line {color['value']}{source[2]}{color['dull']}, in " - f"{color['callable']}{fn_name}{color['dull']}:{source[0]}" + f"program counter {color['value']}{pc}{color['dull']}:\n {color['dull']}" + f"File {color['string']}\"{source[1]}\"{color['dull']}, line{ln}{color['dull']}," + f" in {color['callable']}{fn_name}{color['dull']}:{source[0]}" ) @@ -562,10 +623,23 @@ def _step_compare(a, b): return a['depth'] == b['depth'] and a['jumpDepth'] == b['jumpDepth'] +def _check_last(trace_index): + initial = trace_index[0][1:] + try: + trace = next(i for i in trace_index[1:-1] if i[1:] == initial) + except StopIteration: + return "\u2514", " " + i = trace_index[1:].index(trace) + 2 + next_ = trace_index[i][1:] + if next_[0] < initial[0] or (next_[0] == initial[0] and next_[1] <= initial[1]): + return "\u2514", " " + return "\u251c", "\u2502 " + + def _step_print(step, last_step, indent, start, stop): - print_str = f"\n{' '*indent}{color['dull']}" - if indent: - print_str += "\u221f " + print_str = f"\n{color['dull']}" + if indent is not None: + print_str += f"{indent}\u2500" if last_step['op'] in {"REVERT", "INVALID"} and _step_compare(step, last_step): contract_color = color("error") else: @@ -580,3 +654,7 @@ def _get_memory(step, idx): offset = int(step['stack'][idx], 16) * 2 length = int(step['stack'][idx-1], 16) * 2 return HexBytes("".join(step['memory'])[offset:offset+length]) + + +def _raise(msg, source): + raise VirtualMachineError({"message": msg, "source": source}) diff --git a/brownie/network/web3.py b/brownie/network/web3.py index 9ad389d00..63bbcbd01 100644 --- a/brownie/network/web3.py +++ b/brownie/network/web3.py @@ -8,7 +8,7 @@ Web3 as _Web3 ) -from brownie.types.types import _Singleton +from brownie._singleton import _Singleton class Web3(_Web3, metaclass=_Singleton): diff --git a/brownie/project/__init__.py b/brownie/project/__init__.py index 49e624581..92ba306c5 100644 --- a/brownie/project/__init__.py +++ b/brownie/project/__init__.py @@ -8,7 +8,8 @@ close, compile_source ) +from .scripts import run -__all__ = ['__brownie_import_all__'] +__all__ = ['__brownie_import_all__', 'run'] __brownie_import_all__ = None diff --git a/brownie/project/build.py b/brownie/project/build.py index 8b7d59cf5..ae5c94123 100644 --- a/brownie/project/build.py +++ b/brownie/project/build.py @@ -85,8 +85,8 @@ def load(project_path): _project_path = Path(project_path) for path in list(_project_path.glob('build/contracts/*.json')): try: - with path.open() as f: - build_json = json.load(f) + with path.open() as fp: + build_json = json.load(fp) except json.JSONDecodeError: build_json = {} if ( @@ -104,8 +104,8 @@ def add(build_json): Args: build_json - dictionary of build data to add.''' - with _absolute(build_json['contractName']).open('w') as f: - json.dump(build_json, f, sort_keys=True, indent=2, default=sorted) + with _absolute(build_json['contractName']).open('w') as fp: + json.dump(build_json, fp, sort_keys=True, indent=2, default=sorted) _add(build_json) @@ -183,7 +183,7 @@ def _stem(contract_name): def _absolute(contract_name): contract_name = _stem(contract_name) - return _project_path.joinpath('build/contracts/{}.json'.format(contract_name)) + return _project_path.joinpath(f"build/contracts/{contract_name}.json") def _get_offset(offset_map, name, offset): diff --git a/brownie/project/compiler.py b/brownie/project/compiler.py index 08c45f678..d41dec038 100644 --- a/brownie/project/compiler.py +++ b/brownie/project/compiler.py @@ -97,9 +97,9 @@ def compile_from_input_json(input_json, silent=True): optimizer = input_json['settings']['optimizer'] if not silent: print("Compiling contracts...") - print("Optimizer: {}".format( - "Enabled Runs: "+str(optimizer['runs']) if - optimizer['enabled'] else "Disabled" + print("Optimizer: " + ( + f"Enabled Runs: {optimizer['runs']}" if + optimizer['enabled'] else 'Disabled' )) try: return solcx.compile_standard( @@ -140,7 +140,7 @@ def generate_build_json(input_json, output_json, compiler_data={}, silent=True): for path, contract_name in [(k, v) for k in path_list for v in output_json['contracts'][k]]: if not silent: - print(" - {}...".format(contract_name)) + print(f" - {contract_name}...") evm = output_json['contracts'][path][contract_name]['evm'] node = next(i[contract_name] for i in source_nodes if i.name == path) @@ -185,11 +185,7 @@ def format_link_references(evm): bytecode = evm['bytecode']['object'] references = [(k, x) for v in evm['bytecode']['linkReferences'].values() for k, x in v.items()] for n, loc in [(i[0], x['start']*2) for i in references for x in i[1]]: - bytecode = "{}__{:_<36}__{}".format( - bytecode[:loc], - n[:36], - bytecode[loc+40:] - ) + bytecode = f"{bytecode[:loc]}__{n[:36]:_<36}__{bytecode[loc+40:]}" return bytecode diff --git a/brownie/project/main.py b/brownie/project/main.py index ba25afce6..946e3d8e7 100644 --- a/brownie/project/main.py +++ b/brownie/project/main.py @@ -10,6 +10,7 @@ from brownie.network.contract import ContractContainer from brownie.exceptions import ProjectAlreadyLoaded, ProjectNotFound from brownie.project import build, sources, compiler +from brownie.test import coverage from brownie._config import ARGV, CONFIG, load_project_config FOLDERS = [ @@ -70,7 +71,7 @@ def pull(project_name, project_path=None, ignore_subfolder=False): project_path = Path('.').joinpath(project_name) project_path = _new_checks(project_path, ignore_subfolder) if project_path.exists(): - raise FileExistsError("Folder already exists - {}".format(project_path)) + raise FileExistsError(f"Folder already exists - {project_path}") print(f"Downloading from {url}...") request = requests.get(url) @@ -105,9 +106,10 @@ def close(raises=True): return raise ProjectNotFound("No Brownie project currently open.") - # clear sources and build + # clear sources, build, coverage sources.clear() build.clear() + coverage.clear() # remove objects from namespace for name in sys.modules['brownie.project'].__all__.copy(): @@ -119,7 +121,10 @@ def close(raises=True): sys.modules['brownie.project'].__all__ = ['__brownie_import_all__'] # clear paths - sys.path.remove(CONFIG['folders']['project']) + try: + sys.path.remove(CONFIG['folders']['project']) + except ValueError: + pass CONFIG['folders']['project'] = None @@ -145,9 +150,7 @@ def load(project_path=None): ''' # checks if CONFIG['folders']['project']: - raise ProjectAlreadyLoaded( - "Project has already been loaded at {}".format(CONFIG['folders']['project']) - ) + raise ProjectAlreadyLoaded(f"Project already loaded at {CONFIG['folders']['project']}") if project_path is None: project_path = check_for_project('.') if not project_path or not Path(project_path).joinpath("brownie-config.json").exists(): diff --git a/brownie/project/scripts.py b/brownie/project/scripts.py new file mode 100644 index 000000000..c9d2edbc4 --- /dev/null +++ b/brownie/project/scripts.py @@ -0,0 +1,98 @@ +#!/usr/bin/python3 + +import ast +from hashlib import sha1 +import importlib +from pathlib import Path +import sys + +from brownie.cli.utils import color +from brownie.test.output import print_gas_profile +from brownie.project import check_for_project + + +def run(script_path, method_name="main", args=None, kwargs=None, gas_profile=False): + '''Loads a project script and runs a method in it. + + script_path: path of script to load + method_name: name of method to run + args: method args + kwargs: method kwargs + gas_profile: if True, gas use data will be shown + + Returns: return value from called method + ''' + if args is None: + args = tuple() + if kwargs is None: + kwargs = {} + script_path = _get_path(script_path, "scripts") + module = _import_from_path(script_path) + name = module.__name__ + if not hasattr(module, method_name): + raise AttributeError(f"Module '{name}' has no method '{method_name}'") + print( + f"\nRunning '{color['module']}{name}{color}.{color['callable']}{method_name}{color}'..." + ) + result = getattr(module, method_name)(*args, **kwargs) + if gas_profile: + print_gas_profile() + return result + + +def _get_path(path_str, default_folder="scripts"): + '''Returns path to a python module. + + Args: + path_str: module path + default_folder: default folder path to check if path_str is not found + + Returns: Path object''' + if not path_str.endswith('.py'): + path_str += ".py" + path = Path(path_str) + if not path.exists() and not path.is_absolute(): + if not path_str.startswith(default_folder+'/'): + path = Path(default_folder).joinpath(path_str) + if not path.exists() and sys.path[0]: + path = Path(sys.path[0]).joinpath(path) + if not path.exists(): + raise FileNotFoundError(f"Cannot find {path_str}") + if not path.is_file(): + raise FileNotFoundError(f"{path_str} is not a file") + if path.suffix != ".py": + raise TypeError(f"'{path_str}' is not a python script") + return path + + +def _import_from_path(path): + '''Imports a module from the given path.''' + path = Path(path).absolute().relative_to(sys.path[0]) + import_str = ".".join(path.parts[:-1]+(path.stem,)) + return importlib.import_module(import_str) + + +def get_ast_hash(path): + '''Generates a hash based on the AST of a script. + + Args: + path: path of the script to hash + + Returns: sha1 hash as bytes''' + with Path(path).open() as fp: + ast_list = [ast.parse(fp.read(), path)] + base_path = str(check_for_project(path)) + for obj in [i for i in ast_list[0].body if type(i) in (ast.Import, ast.ImportFrom)]: + if type(obj) is ast.Import: + name = obj.names[0].name + else: + name = obj.module + try: + origin = importlib.util.find_spec(name).origin + except Exception as e: + raise type(e)(f"in {path} - {e}") from None + if base_path in origin: + with open(origin) as fp: + ast_list.append(ast.parse(fp.read(), origin)) + dump = "\n".join(ast.dump(i) for i in ast_list) + return sha1(dump.encode()).hexdigest() diff --git a/brownie/project/sources.py b/brownie/project/sources.py index e5da24e1e..82fe5f141 100644 --- a/brownie/project/sources.py +++ b/brownie/project/sources.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 from hashlib import sha1 +import itertools from pathlib import Path import re import textwrap @@ -55,8 +56,8 @@ def load(project_path): for path in project_path.glob('contracts/**/*.sol'): if "/_" in str(path): continue - with path.open() as f: - source = f.read() + with path.open() as fp: + source = fp.read() path = str(path.relative_to(project_path)) _source[path] = source _contracts.update(_get_contract_data(source, path)) @@ -66,7 +67,7 @@ def minify(source): '''Given contract source as a string, returns a minified version and an offset map.''' offsets = [(0, 0)] - pattern = "({})".format("|".join(MINIFY_REGEX_PATTERNS)) + pattern = f"({'|'.join(MINIFY_REGEX_PATTERNS)})" for match in re.finditer(pattern, source): offsets.append(( match.start() - offsets[-1][1], @@ -90,7 +91,7 @@ def _get_contract_data(full_source, path): offset = minified_source.index(source) if name in _contracts and not _contracts[name]['path'].startswith('" for i in itertools.count() if f"" not in _source) _source[path] = source _contracts.update(_get_contract_data(source, path)) return compiler.compile_and_format({path: source}, optimize=optimize, runs=runs, silent=True) @@ -173,17 +172,23 @@ def get_highlighted_source(path, offset, pad=3): pad_stop = newlines.index(next(i for i in newlines if i >= offset[1])) except StopIteration: return - ln = pad_start + 1 + + ln = (pad_start + 1, pad_stop + 1) pad_start = newlines[max(pad_start-(pad+1), 0)] pad_stop = newlines[min(pad_stop+pad, len(newlines)-1)] - final = "{1}{0}{2}{0[dull]}{3}{0}".format( - color, - source[pad_start:offset[0]], - source[offset[0]:offset[1]], - source[offset[1]:pad_stop] - ) - final = color('dull')+textwrap.indent(textwrap.dedent(final), " ") + final = textwrap.indent(f"{color['dull']}"+textwrap.dedent( + f"{source[pad_start:offset[0]]}{color}" + f"{source[offset[0]:offset[1]]}{color['dull']}{source[offset[1]:pad_stop]}{color}" + ), " ") + + count = source[pad_start:offset[0]].count("\n") + final = final.replace("\n ", f"\n{color['dull']} ", count) + count = source[offset[0]:offset[1]].count('\n') + final = final.replace('\n ', f"\n{color} ", count) + count = source[offset[1]:pad_stop].count("\n") + final = final.replace("\n ", f"\n{color['dull']} ", count) + return final, path, ln diff --git a/brownie/test/__init__.py b/brownie/test/__init__.py index d7d3ceb58..a93a4bf16 100644 --- a/brownie/test/__init__.py +++ b/brownie/test/__init__.py @@ -1,6 +1 @@ #!/usr/bin/python3 - -from .main import ( # noqa F401 - run_tests, - run_script -) diff --git a/brownie/test/check.py b/brownie/test/check.py deleted file mode 100644 index 7c1ceb471..000000000 --- a/brownie/test/check.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/python3 - -'''Assertion methods for writing brownie unit tests.''' - -from brownie.types import KwargTuple -from brownie.types.convert import wei -from brownie.network.transaction import VirtualMachineError - -__console_dir__ = [ - 'true', - 'false', - 'confirms', - 'reverts', - 'event_fired', - 'event_not_fired', - 'equal', - 'not_equal' -] - - -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.''' - if statement and statement is not True: - raise AssertionError(fail_msg+" (evaluated truthfully but not True)") - if not statement: - raise AssertionError(fail_msg) - - -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.''' - if not statement and statement is not False: - raise AssertionError(fail_msg+" (evaluated falsely but not False)") - if statement: - raise AssertionError(fail_msg) - - -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: - tx = fn(*args) - except VirtualMachineError as e: - raise AssertionError(f"{fail_msg}\n {e.source}") - return tx - - -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: - fn(*args) - except VirtualMachineError as e: - if not revert_msg or revert_msg == e.revert_msg: - return - raise AssertionError(f"Reverted with '{e.revert_msg}', expected '{revert_msg}'\n{e.source}") - raise AssertionError("Expected transaction to revert") - - -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. - count: Number of times the event should fire. If left as None, - the event is expected to fire one or more times. - values: A dict or list of dicts of {key:value} that must match - against the fired events with the given name. The length of - values must also match the number of events that fire.''' - if values is not None: - if type(values) is dict: - values = [values] - if type(values) not in (tuple, list): - raise TypeError("Event values must be given as a dict or list of dicts.") - if count is not None: - if values is not None and len(values) != count: - raise ValueError("Required count does not match length of required values.") - if count != tx.events.count(name): - raise AssertionError( - f"Event {name} - expected {count} events to fire, got {tx.events.count(name)}" - ) - elif count is None and not tx.events.count(name): - raise AssertionError(f"Expected event '{name}' to fire") - if values is None: - return - for i in range(len(values)): - for k, v in values[i].items(): - event = tx.events[name][i] - if k not in event: - raise AssertionError(f"Event '{name}' does not contain value '{k}'") - if event[k] != v: - raise AssertionError(f"Event {name} - expected '{k}' to equal {v}, got {event[k]}") - - -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 name in tx.events: - raise AssertionError(fail_msg) - - -def equal(a, b, fail_msg="Expected values to be equal", strict=False): - '''Expects two values to be equal. - - Args: - a: First value. - b: Second value. - fail_msg: Message to show if check fails.''' - if not _compare_input(a, b, strict): - raise AssertionError(f"{fail_msg}: {a} != {b}") - - -def not_equal(a, b, fail_msg="Expected values to be not equal", strict=False): - '''Expects two values to be not equal. - - Args: - a: First value. - b: Second value. - fail_msg: Message to show if check fails.''' - if _compare_input(a, b, strict): - raise AssertionError(f"{fail_msg}: {a} == {b}") - - -def _compare_input(a, b, strict=False): - if type(a) not in (tuple, list, KwargTuple): - types_ = set([type(a), type(b)]) - if dict in types_: - return a == b - if strict or types_.intersection([bool, type(None)]): - return a is b - return _convert_str(a) == _convert_str(b) - if type(b) not in (tuple, list, KwargTuple) or len(b) != len(a): - return False - return next((False for i in range(len(a)) if not _compare_input(a[i], b[i], strict)), True) - - -def _convert_str(value): - if type(value) is not str: - if not hasattr(value, 'address'): - return value - value = value.address - if value.startswith('0x'): - return "0x" + value.lstrip('0x').lower() - if value.count(" ") != 1: - return value - try: - return wei(value) - except ValueError: - return value diff --git a/brownie/test/coverage.py b/brownie/test/coverage.py index ae44aa28f..54b7ca8d1 100644 --- a/brownie/test/coverage.py +++ b/brownie/test/coverage.py @@ -1,267 +1,64 @@ #!/usr/bin/python3 from copy import deepcopy -import json -from pathlib import Path -from brownie.project import build +_coverage_eval = {} +_cached = {} +_active_txhash = set() -def analyze(history, coverage_eval=None): - '''Analyzes contract coverage. - Arguments: - history: List of TransactionReceipt objects. +def add(txhash, coverage_eval): + _coverage_eval[txhash] = coverage_eval + _active_txhash.add(txhash) - Returns: - { "ContractName": { - "statements": { "path/to/file": {index, index, ..}, .. }, - "branches": { - "true": {"path/to/file": {index, index, ..}, .. }, - "false": {"path/to/file": {index, index, ..}, .. }, - }, .. - } }''' - if coverage_eval is None: - coverage_eval = {} - for tx in filter(lambda k: k.trace, history): - build_json = {'contractName': None} - tx_trace = tx.trace - active_branches = set() - for i in filter(lambda k: tx_trace[k]['source'], range(len(tx_trace))): - trace = tx.trace[i] - name = trace['contractName'] - if not build.contains(name): - continue - if build_json['contractName'] != name: - build_json = build.get(name) - coverage_eval = _set_coverage_defaults(build_json, coverage_eval) - pc = build_json['pcMap'][trace['pc']] - if 'statement' in pc: - coverage_eval[name]['statements'][pc['path']].add(pc['statement']) - if 'branch' in pc: - if pc['op'] != "JUMPI": - active_branches.add(pc['branch']) - elif pc['branch'] in active_branches: - key = "false" if tx.trace[i+1]['pc'] == trace['pc']+1 else "true" - coverage_eval[name]['branches'][key][pc['path']].add(pc['branch']) - active_branches.remove(pc['branch']) - return coverage_eval +def add_cached(txhash, coverage_eval): + _cached[txhash] = coverage_eval -def _set_coverage_defaults(build_json, coverage_eval): - name = build_json['contractName'] - if name in coverage_eval: - return coverage_eval - coverage_eval[name] = { - 'statements': dict((k, set()) for k in build_json['coverageMap']['statements'].keys()), - 'branches': { - "true": dict((k, set()) for k in build_json['coverageMap']['branches'].keys()), - "false": dict((k, set()) for k in build_json['coverageMap']['branches'].keys()) - } - } - return coverage_eval +def add_from_cached(txhash, active=True): + if txhash in _cached: + _coverage_eval[txhash] = _cached.pop(txhash) + if active: + _active_txhash.add(txhash) + return txhash in _coverage_eval -def merge_files(coverage_files): - '''Merges multiple coverage evaluation dicts that have been saved to json.''' - coverage_eval = [] - for i in coverage_files: - with Path(i).open() as f: - coverage_eval.append(json.load(f)['coverage']) - return merge(coverage_eval) +def get_and_clear_active(): + result = sorted(_active_txhash) + _active_txhash.clear() + return result + + +def get_all(): + return {**_cached, **_coverage_eval} -def merge(coverage_eval_list): - '''Merges multiple coverage evaluation dicts. - Arguments: - coverage_eval_list: A list of coverage eval dicts. +def get_merged(): + '''Merges multiple coverage evaluation dicts. Returns: coverage eval dict. ''' - merged_eval = deepcopy(coverage_eval_list[0]) - for cov in coverage_eval_list[1:]: - for name in cov: + if not _coverage_eval: + return {} + coverage_eval_list = list(_coverage_eval.values()) + merged_eval = deepcopy(coverage_eval_list.pop()) + for coverage_eval in coverage_eval_list: + for name in coverage_eval: if name not in merged_eval: - merged_eval[name] = cov[name] + merged_eval[name] = coverage_eval[name] continue - _merge(merged_eval[name]['statements'], cov[name]['statements']) - _merge(merged_eval[name]['branches']['true'], cov[name]['branches']['true']) - _merge(merged_eval[name]['branches']['false'], cov[name]['branches']['false']) + for path, map_ in coverage_eval[name].items(): + if path not in merged_eval[name]: + merged_eval[name][path] = map_ + continue + for i in range(3): + merged_eval[name][path][i] = set(merged_eval[name][path][i]).union(map_[i]) return merged_eval -def _merge(original, new): - for path, map_ in new.items(): - if path not in original: - original[path] = map_ - continue - original[path] = set(original[path]).union(map_) - - -def split_by_fn(coverage_eval): - '''Splits a coverage eval dict so that coverage indexes are stored by contract - function. Once done, the dict is no longer compatible with other methods in this module. - - Original format: - {"path/to/file": [index, ..], .. } - - New format: - {"path/to/file": { "ContractName.functionName": [index, .. ], .. } - ''' - results = dict((i, { - 'statements': {}, - 'branches': {'true': {}, 'false': {}}} - ) for i in coverage_eval) - for name in coverage_eval: - coverage_map = build.get(name)['coverageMap'] - results[name]['statements'] = _split_path( - coverage_eval[name]['statements'], - coverage_map['statements'] - ) - for key in ('true', 'false'): - results[name]['branches'][key] = _split_path( - coverage_eval[name]['branches'][key], - coverage_map['branches'] - ) - return results - - -def _split_path(coverage_eval, coverage_map): - results = {} - for path, eval_ in coverage_eval.items(): - results[path] = _split_fn(eval_, coverage_map[path]) - return results - - -def _split_fn(coverage_eval, coverage_map): - results = {} - for fn, map_ in coverage_map.items(): - results[fn] = [i for i in map_ if int(i) in coverage_eval] - return results - - -def get_totals(coverage_eval): - '''Returns a modified coverage eval dict showing counts and totals for each - contract function. - - Arguments: - coverage_eval: Standard coverage evaluation dict - - Returns: - { "ContractName": { - "statements": { - "path/to/file": { - "ContractName.functionName": (count, total), .. - }, .. - }, - "branches" { - "path/to/file": { - "ContractName.functionName": (true count, false count, total), .. - }, .. - } - }''' - coverage_eval = split_by_fn(coverage_eval) - results = dict((i, { - 'statements': {}, - 'totals': {'statements': 0, 'branches': [0, 0]}, - 'branches': {'true': {}, 'false': {}}} - ) for i in coverage_eval) - for name in coverage_eval: - coverage_map = build.get(name)['coverageMap'] - r = results[name] - r['statements'], r['totals']['statements'] = _statement_totals( - coverage_eval[name]['statements'], - coverage_map['statements'] - ) - r['branches'], r['totals']['branches'] = _branch_totals( - coverage_eval[name]['branches'], - coverage_map['branches'] - ) - return results - - -def _statement_totals(coverage_eval, coverage_map): - result = {} - count, total = 0, 0 - for path, fn in [(k, x) for k, v in coverage_eval.items() for x in v]: - count += len(coverage_eval[path][fn]) - total += len(coverage_map[path][fn]) - result[fn] = (len(coverage_eval[path][fn]), len(coverage_map[path][fn])) - return result, (count, total) - - -def _branch_totals(coverage_eval, coverage_map): - result = {} - final = [0, 0, 0] - for path, fn in [(k, x) for k, v in coverage_map.items() for x in v]: - true = len(coverage_eval['true'][path][fn]) - false = len(coverage_eval['false'][path][fn]) - total = len(coverage_map[path][fn]) - result[fn] = (true, false, total) - for i in range(3): - final[i] += result[fn][i] - return result, final - - -def get_highlights(coverage_eval): - '''Returns a highlight map formatted for display in the GUI. - - Arguments: - coverage_eval: coverage evaluation dict - - Returns: - { - "statements": { - "ContractName": {"path/to/file": [start, stop, color, msg .. ], .. }, - }, - "branches": { - "ContractName": {"path/to/file": [start, stop, color, msg .. ], .. }, - } - }''' - results = { - 'statements': {}, - 'branches': {} - } - for name in coverage_eval: - coverage_map = build.get(name)['coverageMap'] - results['statements'][name] = _statement_highlights( - coverage_eval[name]['statements'], - coverage_map['statements'] - ) - results['branches'][name] = _branch_highlights( - coverage_eval[name]['branches'], - coverage_map['branches'] - ) - return results - - -def _statement_highlights(coverage_eval, coverage_map): - results = dict((i, []) for i in coverage_map) - for path, fn in [(k, x) for k, v in coverage_map.items() for x in v]: - results[path].extend([ - list(offset) + ["green" if int(i) in coverage_eval[path] else "red", ""] - for i, offset in coverage_map[path][fn].items() - ]) - return results - - -def _branch_highlights(coverage_eval, coverage_map): - results = dict((i, []) for i in coverage_map) - for path, fn in [(k, x) for k, v in coverage_map.items() for x in v]: - results[path].extend([ - list(offset[:2]) + - [_branch_color(int(i), coverage_eval, path, offset[2]), ""] - for i, offset in coverage_map[path][fn].items() - ]) - return results - - -def _branch_color(i, coverage_eval, path, jump): - if i in coverage_eval['true'][path]: - if i in coverage_eval['false'][path]: - return "green" - return "yellow" if jump else "orange" - if i in coverage_eval['false'][path]: - return "orange" if jump else "yellow" - return "red" +def clear(): + _coverage_eval.clear() + _cached.clear() + _active_txhash.clear() diff --git a/brownie/test/executor.py b/brownie/test/executor.py deleted file mode 100644 index cd4401df4..000000000 --- a/brownie/test/executor.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/python3 - -from requests.exceptions import ReadTimeout -import sys -import time - -from . import pathutils, loader -from .output import TestPrinter -from brownie.network import history, rpc -from brownie.cli.utils import color, notify -from brownie._config import ARGV, CONFIG -from brownie.test import coverage -from brownie.exceptions import ExpectedFailing -import brownie.network as network -from brownie.network.contract import Contract - - -def run_test_modules(test_paths, only_update=True, check_coverage=False, save=True): - '''Runs tests across one or more modules. - - Args: - test_paths: list of test module paths - only_update: if True, will only run tests that were not previous run or where - changes to related files have occured - check_coverage: if True, test coverage will also be evaluated - save: if True, results are saved in build/tests - - Returns: None - ''' - test_data = _get_data(test_paths, only_update, check_coverage) - if not test_data: - if test_paths and only_update: - notify("SUCCESS", "All test results are up to date.") - else: - notify("WARNING", "No tests were found.") - return - TestPrinter.set_grand_total(len(test_data)) - count = sum([len([x for x in i[2] if x[0].__name__ != "setup"]) for i in test_data]) - print(f"Running {count} test{_s(count)} across {len(test_data)} module{_s(len(test_data))}.") - - if not network.is_connected(): - network.connect() - if check_coverage: - ARGV['always_transact'] = True - - traceback_info = [] - failed = 0 - start_time = time.time() - try: - for (test_path, old_coverage_eval, method_data) in test_data: - tb, coverage_eval, contracts = _run_module(test_path, method_data, check_coverage) - if tb: - traceback_info += tb - failed += 1 - if not save: - continue - - contract_names = set(i._name for i in contracts) - contract_names |= set(x for i in contracts for x in i._build['dependencies']) - pathutils.save_build_json( - test_path, - "passing" if not tb else "failing", - coverage_eval or old_coverage_eval, - contract_names - ) - - if not traceback_info: - print() - notify("SUCCESS", "All tests passed.") - return True - return False - except KeyboardInterrupt: - print("\n\nTest execution has been terminated by KeyboardInterrupt.") - return - finally: - if check_coverage: - del ARGV['always_transact'] - print(f"\nTotal runtime: {time.time() - start_time:.4f}s") - if traceback_info: - count = len(traceback_info) - notify("WARNING", f"{count} test{_s(count)} failed in {failed} module{_s(failed)}.") - for err in traceback_info: - print(f"\nTraceback for {err[0]}:\n{err[1]}") - - -def _get_data(test_paths, only_update, check_coverage): - test_data = [] - for path in test_paths: - build_json = pathutils.get_build_json(path) - if ( - only_update and build_json['result'] == "passing" and - (build_json['coverage'] or not check_coverage) - ): - continue - fn_list = loader.get_methods(path, check_coverage) - if not fn_list: - continue - # test path, build data, list of (fn, args) - test_data.append((path, build_json['coverage'], fn_list)) - return test_data - - -def _run_module(test_path, method_data, check_coverage): - rpc.reset() - - has_setup = method_data[0][0].__name__ == "setup" - printer = TestPrinter( - str(test_path).replace(CONFIG['folders']['project'], "."), - 0 if has_setup else 1, - len(method_data) - (1 if has_setup else 0) - ) - - coverage_eval = {} - if has_setup: - tb, coverage_eval = _run_method(*method_data[0], {}, printer, check_coverage) - if tb: - printer.finish() - return tb, {}, set() - del method_data[0] - rpc.snapshot() - traceback_info = [] - contracts = _get_contracts() - - for fn, args in method_data: - history.clear() - rpc.revert() - tb, coverage_eval = _run_method(fn, args, coverage_eval, printer, check_coverage) - contracts |= _get_contracts() - traceback_info += tb - if tb and tb[0][2] == ReadTimeout: - raise ReadTimeout( - "Timeout while communicating with RPC. Possibly the local client has crashed." - ) - - printer.finish() - return traceback_info, coverage_eval, contracts - - -def _run_method(fn, args, coverage_eval, printer, check_coverage): - desc = fn.__doc__ or fn.__name__ - if args['skip']: - printer.skip(desc) - return [], coverage_eval - printer.start(desc) - traceback_info, printer_args = [], [] - try: - if check_coverage and 'always_transact' in args: - ARGV['always_transact'] = args['always_transact'] - fn() - if check_coverage: - ARGV['always_transact'] = True - if args['pending']: - raise ExpectedFailing("Test was expected to fail") - except Exception as e: - printer_args = [e, args['pending']] - if not args['pending'] or type(e) == ExpectedFailing: - traceback_info = [( - f"{color['module']}{fn.__module__}.{color['callable']}{fn.__name__}{color}", - color.format_tb(sys.exc_info(), sys.modules[fn.__module__].__file__), - type(e) - )] - if check_coverage: - coverage_eval = coverage.analyze(history.copy(), coverage_eval) - printer.stop(*printer_args) - return traceback_info, coverage_eval - - -def _get_contracts(): - return set( - i.contract_address for i in history if type(i.contract_address) is Contract and - not i.contract_address._build['sourcePath'].startswith(' 1) - raise ValueError(f"{path.name}: multiple methods of same name - {', '.join(duplicates)}") - - default_args = {} - setup_fn = next((i for i in fn_nodes if i.name == "setup"), False) - if setup_fn: - # apply default arguments across all methods in the module - default_args = _get_args(setup_fn, {}, check_coverage) - if 'skip' in default_args and default_args['skip']: - return [] - fn_nodes.remove(setup_fn) - fn_nodes.insert(0, setup_fn) - module = import_from_path(path) - return [(getattr(module, i.name), _get_args(i, default_args, check_coverage)) for i in fn_nodes] - - -def _get_args(node, defaults={}, check_coverage=False): - args = dict(( - node.args.args[i].arg, - _coverage_to_bool(node.args.defaults[i], check_coverage) - ) for i in range(len(list(node.args.args)))) - return FalseyDict({**defaults, **args}) - - -def _coverage_to_bool(node, check_coverage): - value = getattr(node, node._fields[0]) - return value if value != "coverage" else check_coverage diff --git a/brownie/test/main.py b/brownie/test/main.py deleted file mode 100644 index 3ff17be23..000000000 --- a/brownie/test/main.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/python3 - -from brownie.cli.utils import color -from . import ( - pathutils, - loader, - executor, - coverage, - output -) - - -def run_tests(test_path, only_update=True, check_coverage=False, gas_profile=False): - '''Finds and runs tests for a project. - - test_path: path to locate tests in - only_update: if True, will only run tests that were not previous run or where - changes to related files have occured - check_coverage: if True, test coverage will also be evaluated - gas_profile: if True, gas use data will be shown - ''' - base_path = pathutils.check_for_project(test_path or ".") - pathutils.check_build_hashes(base_path) - pathutils.remove_empty_folders(base_path.joinpath('build/tests')) - test_paths = pathutils.get_paths(test_path, "tests") - if not executor.run_test_modules(test_paths, only_update, check_coverage, True): - return - if check_coverage: - build_paths = pathutils.get_build_paths(test_paths) - coverage_eval = coverage.merge_files(build_paths) - output.coverage_totals(coverage_eval) - pathutils.save_report(coverage_eval, base_path.joinpath("reports")) - if gas_profile: - output.gas_profile() - - -def run_script(script_path, method_name="main", args=(), kwargs={}, gas_profile=False): - '''Loads a project script and runs a method in it. - - script_path: path of script to load - method_name: name of method to run - args: method args - kwargs: method kwargs - gas_profile: if True, gas use data will be shown - - Returns: return value from called method - ''' - script_path = pathutils.get_path(script_path, "scripts") - module = loader.import_from_path(script_path) - if not hasattr(module, method_name): - raise AttributeError(f"Module '{module.__name__}' has no method '{method_name}'") - print( - f"\nRunning '{color['module']}{module.__name__}{color}." - f"{color['callable']}{method_name}{color}'..." - ) - result = getattr(module, method_name)(*args, **kwargs) - if gas_profile: - output.gas_profile() - return result diff --git a/brownie/test/manager.py b/brownie/test/manager.py new file mode 100644 index 000000000..512692c0c --- /dev/null +++ b/brownie/test/manager.py @@ -0,0 +1,156 @@ +#!/usr/bin/python3 + +from hashlib import sha1 +import json +from pathlib import Path + +from brownie.network.history import TxHistory, _ContractHistory +from brownie.project import build +from brownie.project.scripts import get_ast_hash +from brownie.test import coverage +from brownie._config import ARGV + + +STATUS_SYMBOLS = { + 'passed': '.', + 'skipped': 's', + 'failed': 'F' +} + +STATUS_TYPES = { + '.': "passed", + 's': "skipped", + 'F': "failed", + 'E': "error", + 'x': "xfailed", + 'X': "xpassed" +} + +history = TxHistory() +_contracts = _ContractHistory() + + +class TestManager: + + def __init__(self, path): + self.project_path = path + self.active_path = None + self.count = 0 + self.results = None + self.isolated = set() + self.conf_hashes = dict( + (self._path(i.parent), get_ast_hash(i)) for i in Path(path).glob('tests/**/conftest.py') + ) + try: + with path.joinpath('build/tests.json').open() as fp: + hashes = json.load(fp) + except (FileNotFoundError, json.decoder.JSONDecodeError): + hashes = {'tests': {}, 'contracts': {}, 'tx': {}} + + self.tests = dict( + (k, v) for k, v in hashes['tests'].items() if + Path(k).exists() and self._get_hash(k) == v['sha1'] + ) + self.contracts = dict((k, v['bytecodeSha1']) for k, v in build.items() if v['bytecode']) + changed_contracts = set( + k for k, v in hashes['contracts'].items() if + k not in self.contracts or v != self.contracts[k] + ) + if changed_contracts: + for txhash, coverage_eval in hashes['tx'].items(): + if not changed_contracts.intersection(coverage_eval.keys()): + coverage.add_cached(txhash, coverage_eval) + self.tests = dict( + (k, v) for k, v in self.tests.items() if v['isolated'] is not False + and not changed_contracts.intersection(v['isolated']) + ) + else: + for txhash, coverage_eval in hashes['tx'].items(): + coverage.add_cached(txhash, coverage_eval) + + def _path(self, path): + return str(Path(path).absolute().relative_to(self.project_path)) + + def set_isolated_modules(self, paths): + self.isolated = set(self._path(i) for i in paths) + + def _get_hash(self, path): + hash_ = get_ast_hash(path) + for confpath in filter(lambda k: k in path, sorted(self.conf_hashes)): + hash_ += self.conf_hashes[confpath] + return sha1(hash_.encode()).hexdigest() + + def check_updated(self, path): + path = self._path(path) + if path not in self.tests or not self.tests[path]['isolated']: + return False + if ARGV['coverage'] and not self.tests[path]['coverage']: + return False + for txhash in self.tests[path]['txhash']: + coverage.add_from_cached(txhash, False) + return True + + def module_completed(self, path): + path = self._path(path) + isolated = False + if path in self.isolated: + isolated = [i for i in _contracts.dependencies() if i in self.contracts] + txhash = coverage.get_and_clear_active() + if not ARGV['coverage'] and (path in self.tests and self.tests[path]['coverage']): + txhash = self.tests[path]['txhash'] + self.tests[path] = { + 'sha1': self._get_hash(path), + 'isolated': isolated, + 'coverage': ARGV['coverage'] or (path in self.tests and self.tests[path]['coverage']), + 'txhash': txhash, + 'results': "".join(self.results) + } + + def save_json(self): + txhash = set(x for v in self.tests.values() for x in v['txhash']) + coverage_eval = dict((k, v) for k, v in coverage.get_all().items() if k in txhash) + report = { + 'tests': self.tests, + 'contracts': self.contracts, + 'tx': coverage_eval + } + with self.project_path.joinpath('build/tests.json').open('w') as fp: + json.dump(report, fp, indent=2, sort_keys=True, default=sorted) + + def set_active(self, path): + path = self._path(path) + if path == self.active_path: + self.count += 1 + return + self.active_path = path + self.count = 0 + if path in self.tests and ARGV['update']: + self.results = list(self.tests[path]['results']) + else: + self.results = [] + + def check_status(self, report): + if report.when == "setup": + self._skip = report.skipped + if len(self.results) < self.count+1: + self.results.append("s" if report.skipped else None) + if report.failed: + self.results[self.count] = "E" + return "error", "E", "ERROR" + return "", "", "" + if report.when == "teardown": + if report.failed: + self.results[self.count] = "E" + return "error", "E", "ERROR" + elif self._skip: + report.outcome = STATUS_TYPES[self.results[self.count]] + return "skipped", "s", "SKIPPED" + return "", "", "" + if hasattr(report, "wasxfail"): + self.results[self.count] = 'x' if report.skipped else 'X' + if report.skipped: + return "xfailed", "x", "XFAIL" + elif report.passed: + return "xpassed", "X", "XPASS" + self.results[self.count] = STATUS_SYMBOLS[report.outcome] + return report.outcome, STATUS_SYMBOLS[report.outcome], report.outcome.upper() diff --git a/brownie/test/output.py b/brownie/test/output.py index bdac4dca7..5e6c4b9f8 100644 --- a/brownie/test/output.py +++ b/brownie/test/output.py @@ -1,86 +1,81 @@ #!/usr/bin/python3 -import sys +import json +from pathlib import Path import time - from brownie.cli.utils import color -from brownie.network import history -from . import coverage +from brownie.project import build +from brownie.network.history import TxHistory COVERAGE_COLORS = [ - (0.5, "bright red"), - (0.85, "bright yellow"), + (0.8, "bright red"), + (0.9, "bright yellow"), (1, "bright green") ] -class TestPrinter: +def save_coverage_report(coverage_eval, report_path): + '''Saves a test coverage report for viewing in the GUI. - grand_count = 1 - grand_total = 0 + Args: + coverage_eval: Coverage evaluation dict + report_path: Path to save to. If a folder is given, saves as coverage-ddmmyy - def __init__(self, path, count, total): - self.path = path - self.count = count - self.total = total - self.total_time = time.time() - print( - f"\nRunning {color['module']}{path}{color} - {self.total} test" - f"{'s' if total != 1 else ''} ({self.grand_count}/{self.grand_total})" - ) + Returns: Path object where report file was saved''' + report = { + 'highlights': _get_highlights(coverage_eval), + 'coverage': _get_totals(coverage_eval), + 'sha1': {} # TODO + } + report = json.loads(json.dumps(report, default=sorted)) + report_path = Path(report_path).absolute() + save = True + if report_path.is_dir(): + report_path, save = _check_last_path(report, report_path) + if save: + with report_path.open('w') as fp: + json.dump(report, fp, sort_keys=True, indent=2) + print(f"\nCoverage report saved at {report_path}") + return report_path - @classmethod - def set_grand_total(cls, total): - cls.grand_total = total - def skip(self, description): - self._print( - f"{description} ({color['pending']}skipped{color['dull']})\n", - "\u229d", - "pending", - "dull" - ) - self.count += 1 +def _check_last_path(report, path): + filename = "coverage-"+time.strftime('%d%m%y')+"{}.json" + count = len(list(path.glob(filename.format('*')))) + if count: + last_path = _report_path(path, filename, count-1) + try: + with last_path.open() as fp: + last_report = json.load(fp) + if last_report == report: + return last_path, False + except json.JSONDecodeError: + pass + return _report_path(path, filename, count), True - def start(self, description): - self.desc = description - self._print(f"{description} ({self.count}/{self.total})...") - self.time = time.time() - def stop(self, err=None, expect=False): - if not err: - self._print(f"{self.desc} ({time.time() - self.time:.4f}s) \n", "\u2713") - else: - err = type(err).__name__ - color_str = 'success' if expect and err != "ExpectedFailing" else 'error' - symbol = '\u2717' if err in ("AssertionError", "VirtualMachineError") else '\u203C' - msg = f"{self.desc} ({color(color_str)}{err}{color['dull']})\n" - self._print(msg, symbol, color_str, "dull") - self.count += 1 - - def finish(self): - print( - f"Completed {color['module']}{self.path}{color} ({time.time() - self.total_time:.4f}s)" - ) - TestPrinter.grand_count += 1 +def _report_path(base_path, filename, count): + return base_path.joinpath(filename.format("-"+str(count) if count else "")) - def _print(self, msg, symbol=" ", symbol_color="success", main_color=None): - sys.stdout.write( - f"\r {color[symbol_color]}{symbol}{color[main_color]} {self.count} - {msg}{color}" - ) - sys.stdout.flush() + +def print_gas_profile(): + '''Formats and prints a gas profile report to the console.''' + print('\n\nGas Profile:') + gas = TxHistory().gas_profile + for i in sorted(gas): + print(f"{i} - avg: {gas[i]['avg']:.0f} low: {gas[i]['low']} high: {gas[i]['high']}") -def coverage_totals(coverage_eval): +def print_coverage_totals(coverage_eval): '''Formats and prints a coverage evaluation report to the console. Args: coverage_eval: coverage evaluation dict Returns: None''' - totals = coverage.get_totals(coverage_eval) - print("\nCoverage analysis:") + totals = _get_totals(coverage_eval) + print("\n\nCoverage analysis:") for name in sorted(totals): pct = _pct(totals[name]['totals']['statements'], totals[name]['totals']['branches']) print(f"\n contract: {color['contract']}{name}{color} - {_cov_color(pct)}{pct:.1%}{color}") @@ -88,10 +83,7 @@ def coverage_totals(coverage_eval): for fn_name, count in cov['statements'].items(): branch = cov['branches'][fn_name] if fn_name in cov['branches'] else (0, 0, 0) pct = _pct(count, branch) - print( - f" {color['contract_method']}{fn_name}{color}" - f" - {_cov_color(pct)}{pct:.1%}{color}" - ) + print(f" {fn_name} - {_cov_color(pct)}{pct:.1%}{color}") def _cov_color(pct): @@ -105,9 +97,161 @@ def _pct(statement, branch): return pct -def gas_profile(): - '''Formats and prints a gas profile report to the console.''' - print('\nGas Profile:') - gas = history.gas_profile - for i in sorted(gas): - print(f"{i} - avg: {gas[i]['avg']:.0f} low: {gas[i]['low']} high: {gas[i]['high']}") +def _get_totals(coverage_eval): + '''Returns a modified coverage eval dict showing counts and totals for each + contract function. + + Arguments: + coverage_eval: Standard coverage evaluation dict + + Returns: + { "ContractName": { + "statements": { + "path/to/file": { + "ContractName.functionName": (count, total), .. + }, .. + }, + "branches" { + "path/to/file": { + "ContractName.functionName": (true count, false count, total), .. + }, .. + } + }''' + coverage_eval = _split_by_fn(coverage_eval) + results = dict((i, { + 'statements': {}, + 'totals': {'statements': 0, 'branches': [0, 0]}, + 'branches': {'true': {}, 'false': {}}} + ) for i in coverage_eval) + for name in coverage_eval: + coverage_map = build.get(name)['coverageMap'] + r = results[name] + r['statements'], r['totals']['statements'] = _statement_totals( + coverage_eval[name], + coverage_map['statements'] + ) + r['branches'], r['totals']['branches'] = _branch_totals( + coverage_eval[name], + coverage_map['branches'] + ) + return results + + +def _split_by_fn(coverage_eval): + '''Splits a coverage eval dict so that coverage indexes are stored by contract + function. Once done, the dict is no longer compatible with other methods in this module. + + Original format: + {"path/to/file": [index, ..], .. } + + New format: + {"path/to/file": { "ContractName.functionName": [index, .. ], .. } + ''' + results = dict((i, { + 'statements': {}, + 'branches': {'true': {}, 'false': {}}} + ) for i in coverage_eval) + for name in coverage_eval: + map_ = build.get(name)['coverageMap'] + results[name] = dict((k, _split(v, map_, k)) for k, v in coverage_eval[name].items()) + return results + + +def _split(coverage_eval, coverage_map, key): + results = {} + for fn, map_ in coverage_map['statements'][key].items(): + results[fn] = [[i for i in map_ if int(i) in coverage_eval[0]], [], []] + for fn, map_ in coverage_map['branches'][key].items(): + results[fn][1] = [i for i in map_ if int(i) in coverage_eval[1]] + results[fn][2] = [i for i in map_ if int(i) in coverage_eval[2]] + return results + + +def _statement_totals(coverage_eval, coverage_map): + result = {} + count, total = 0, 0 + for path, fn in [(k, x) for k, v in coverage_eval.items() for x in v]: + count += len(coverage_eval[path][fn][0]) + total += len(coverage_map[path][fn]) + result[fn] = (len(coverage_eval[path][fn][0]), len(coverage_map[path][fn])) + return result, (count, total) + + +def _branch_totals(coverage_eval, coverage_map): + result = {} + final = [0, 0, 0] + for path, fn in [(k, x) for k, v in coverage_map.items() for x in v]: + if path not in coverage_eval: + true, false = 0, 0 + else: + true = len(coverage_eval[path][fn][2]) + false = len(coverage_eval[path][fn][1]) + total = len(coverage_map[path][fn]) + result[fn] = (true, false, total) + for i in range(3): + final[i] += result[fn][i] + return result, final + + +def _get_highlights(coverage_eval): + '''Returns a highlight map formatted for display in the GUI. + + Arguments: + coverage_eval: coverage evaluation dict + + Returns: + { + "statements": { + "ContractName": {"path/to/file": [start, stop, color, msg .. ], .. }, + }, + "branches": { + "ContractName": {"path/to/file": [start, stop, color, msg .. ], .. }, + } + }''' + results = { + 'statements': {}, + 'branches': {} + } + for name, eval_ in coverage_eval.items(): + coverage_map = build.get(name)['coverageMap'] + results['statements'][name] = _statement_highlights(eval_, coverage_map['statements']) + results['branches'][name] = _branch_highlights(eval_, coverage_map['branches']) + return results + + +def _statement_highlights(coverage_eval, coverage_map): + results = dict((i, []) for i in coverage_map) + for path, fn in [(k, x) for k, v in coverage_map.items() for x in v]: + results[path].extend([ + list(offset) + [_statement_color(i, coverage_eval, path), ""] + for i, offset in coverage_map[path][fn].items() + ]) + return results + + +def _statement_color(i, coverage_eval, path): + if path not in coverage_eval or int(i) not in coverage_eval[path][0]: + return "red" + return "green" + + +def _branch_highlights(coverage_eval, coverage_map): + results = dict((i, []) for i in coverage_map) + for path, fn in [(k, x) for k, v in coverage_map.items() for x in v]: + results[path].extend([ + list(offset[:2]) + [_branch_color(int(i), coverage_eval, path, offset[2]), ""] + for i, offset in coverage_map[path][fn].items() + ]) + return results + + +def _branch_color(i, coverage_eval, path, jump): + if path not in coverage_eval: + return "red" + if i in coverage_eval[path][2]: + if i in coverage_eval[path][1]: + return "green" + return "yellow" if jump else "orange" + if i in coverage_eval[path][1]: + return "orange" if jump else "yellow" + return "red" diff --git a/brownie/test/pathutils.py b/brownie/test/pathutils.py deleted file mode 100644 index 7751535da..000000000 --- a/brownie/test/pathutils.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/python3 - -import ast -from hashlib import sha1 -import importlib -import json -import os -from pathlib import Path -import sys -import time - -from . import coverage -from brownie.project import build, check_for_project - - -def check_build_hashes(base_path): - '''Checks the hash data in all test build json files, and deletes those where - hashes have changed. - - Args: - base_path: root path of project to check - - Returns: None''' - base_path = Path(base_path) - build_path = base_path.joinpath('build/tests') - for coverage_json in list(build_path.glob('**/*.json')): - try: - with coverage_json.open() as f: - dependents = json.load(f)['sha1'] - except json.JSONDecodeError: - coverage_json.unlink() - continue - for path, hash_ in dependents.items(): - path = base_path.joinpath(path) - if path.exists(): - if path.suffix != ".json": - if get_ast_hash(path) == hash_: - continue - elif build.get(path.stem)['bytecodeSha1'] == hash_: - continue - coverage_json.unlink() - break - - -def remove_empty_folders(base_path): - '''Removes empty subfolders within the given path.''' - for path in [Path(i[0]) for i in list(os.walk(base_path))[:0:-1]]: - if not list(path.glob('*')): - path.rmdir() - - -def get_ast_hash(path): - '''Generates a hash based on the AST of a script. - - Args: - path: path of the script to hash - - Returns: sha1 hash as bytes''' - with Path(path).open() as f: - ast_list = [ast.parse(f.read(), path)] - base_path = str(check_for_project(path)) - for obj in [i for i in ast_list[0].body if type(i) in (ast.Import, ast.ImportFrom)]: - if type(obj) is ast.Import: - name = obj.names[0].name - else: - name = obj.module - origin = importlib.util.find_spec(name).origin - if base_path in origin: - with open(origin) as f: - ast_list.append(ast.parse(f.read(), origin)) - dump = "\n".join(ast.dump(i) for i in ast_list) - return sha1(dump.encode()).hexdigest() - - -def get_path(path_str, default_folder="scripts"): - '''Returns path to a python module. - - Args: - path_str: module path - default_folder: default folder path to check if path_str is not found - - Returns: Path object''' - if not path_str.endswith('.py'): - path_str += ".py" - path = _get_path(path_str, default_folder) - if not path.is_file(): - raise FileNotFoundError(f"{path_str} is not a file") - return path - - -def get_paths(path_str=None, default_folder="tests"): - '''Returns paths to python modules. - - Args: - path_str: base path to look for modules in - default_folder: default folder path to check if path_str is not found - - Returns: list of Path objects''' - path = _get_path(path_str, default_folder) - if not path.is_dir(): - return [path] - return [i for i in path.absolute().glob('**/[!_]*.py') if "/_" not in str(i)] - - -def _get_path(path_str, default_folder): - path = Path(path_str or default_folder) - if not path.exists() and not path.is_absolute(): - if not path_str.startswith(default_folder+'/'): - path = Path(default_folder).joinpath(path_str) - if not path.exists() and sys.path[0]: - path = Path(sys.path[0]).joinpath(path) - if not path.exists(): - raise FileNotFoundError(f"Cannot find {path_str}") - if path.is_file() and path.suffix != ".py": - raise TypeError(f"'{path_str}' is not a python script") - return path - - -def get_build_paths(test_paths): - '''Given a list of test paths, returns an equivalent list of build paths''' - base_path = check_for_project(test_paths[0]) - build_path = base_path.joinpath('build') - test_paths = [Path(i).absolute().with_suffix('.json') for i in test_paths] - return [build_path.joinpath(i.relative_to(base_path)) for i in test_paths] - - -def get_build_json(test_path): - '''Loads the result data for a given test. If the file cannot be found or is - corrupted, creates the necessary folder structure and returns an appropriately - formatted dict. - - Args: - test_path: path to the test file - - Returns: loaded build data as a dict''' - build_path = get_build_paths([test_path])[0] - if build_path.exists(): - try: - with build_path.open() as f: - return json.load(f) - except json.JSONDecodeError: - build_path.unlink() - for path in list(build_path.parents)[::-1]: - path.mkdir(exist_ok=True) - return {'result': None, 'coverage': {}, 'sha1': {}} - - -def save_build_json(module_path, result, coverage_eval, contract_names): - ''' - Saves the result data for a given test. - - Args: - module_path: path of the test module - result: result of test execution (passing / failing) - coverage_eval: coverage evaluation as a dict - contract_names: list of contracts called by the test - - Returns: None''' - module_path = Path(module_path).absolute() - project_path = check_for_project(module_path) - build_path = get_build_paths([module_path])[0] - build_files = [Path(f"build/contracts/{i}.json") for i in contract_names] - build_json = { - 'result': result, - 'coverage': coverage_eval, - 'sha1': dict((str(i), build.get(i.stem)['bytecodeSha1']) for i in build_files) - } - path = str(module_path.relative_to(project_path)) - build_json['sha1'][path] = get_ast_hash(module_path) - with build_path.open('w') as f: - json.dump(build_json, f, sort_keys=True, indent=2, default=sorted) - - -def save_report(coverage_eval, report_path): - '''Saves a test coverage report for viewing in the GUI. - - Args: - coverage_eval: Coverage evaluation dict - report_path: Path to save to. If a folder is given, saves as coverage-ddmmyy - - Returns: Path object where report file was saved''' - report = { - 'highlights': coverage.get_highlights(coverage_eval), - 'coverage': coverage.get_totals(coverage_eval), - 'sha1': {} # TODO - } - report_path = Path(report_path).absolute() - if report_path.is_dir(): - filename = "coverage-"+time.strftime('%d%m%y')+"{}.json" - count = len(list(report_path.glob(filename.format('*')))) - report_path = report_path.joinpath(filename.format("-"+str(count) if count else "")) - with report_path.open('w') as f: - json.dump(report, f, sort_keys=True, indent=2, default=sorted) - print(f"\nCoverage report saved at {report_path.relative_to(sys.path[0])}") - return report_path diff --git a/brownie/test/plugin.py b/brownie/test/plugin.py new file mode 100644 index 000000000..5232bd743 --- /dev/null +++ b/brownie/test/plugin.py @@ -0,0 +1,200 @@ +#!/usr/bin/python3 + +from pathlib import Path +import pytest + +import brownie +from brownie.test import output +from brownie.test.manager import TestManager +from brownie._config import CONFIG, ARGV + + +class RevertContextManager: + + def __init__(self, revert_msg=None): + self.revert_msg = revert_msg + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type is None: + raise AssertionError("Transaction did not revert") from None + if exc_type is not brownie.exceptions.VirtualMachineError: + raise exc_type(exc_value).with_traceback(traceback) + if self.revert_msg is None or self.revert_msg == exc_value.revert_msg: + return True + raise AssertionError( + f"Unexpected revert string '{exc_value.revert_msg}'\n{exc_value.source}" + ) from None + + +def _generate_fixture(container): + def _fixture(): + yield container + _fixture.__doc__ = f"Provides access to Brownie ContractContainer object '{container._name}'" + return pytest.fixture(scope="session")(_fixture) + + +if brownie.project.check_for_project('.'): + + # load project and generate dynamic fixtures + for container in brownie.project.load(): + globals()[container._name] = _generate_fixture(container) + + # create test manager - for reading and writing to build/test.json + manager = TestManager(Path(CONFIG['folders']['project'])) + pytest.reverts = RevertContextManager + + # set commandline options + def pytest_addoption(parser): + parser.addoption( + '--coverage', '-C', action="store_true", help="Evaluate contract test coverage" + ) + parser.addoption( + '--gas', '-G', action="store_true", help="Display gas profile for function calls" + ) + parser.addoption( + '--update', '-U', action="store_true", help="Only run tests where changes have occurred" + ) + parser.addoption( + '--revert-tb', '-R', action="store_true", help="Show detailed traceback on tx reverts" + ) + parser.addoption( + '--network', + '-N', + default=False, + nargs=1, + help=f"Use a specific network (default {CONFIG['network_defaults']['name']})" + ) + + def pytest_configure(config): + for key in ('coverage', 'always_transact'): + ARGV[key] = config.getoption("--coverage") + ARGV['gas'] = config.getoption("--gas") + ARGV['revert'] = config.getoption('--revert-tb') or CONFIG['test']['revert_traceback'] + ARGV['update'] = config.getoption('--update') + ARGV['network'] = None + if config.getoption('--network'): + ARGV['network'] = config.getoption('--network')[0] + + # plugin hooks + + def pytest_generate_tests(metafunc): + # module_isolation always runs first + fixtures = metafunc.fixturenames + if 'module_isolation' in fixtures: + fixtures.remove('module_isolation') + fixtures.insert(0, 'module_isolation') + # fn_isolation always runs before other function scoped fixtures + if 'fn_isolation' in fixtures: + fixtures.remove('fn_isolation') + defs = metafunc._arg2fixturedefs + idx = next(( + fixtures.index(i) for i in fixtures if + i in defs and defs[i][0].scope == "function" + ), len(fixtures)) + fixtures.insert(idx, 'fn_isolation') + + def pytest_collection_modifyitems(items): + # determine which modules are properly isolated + tests = {} + for i in items: + if 'skip_coverage' in i.fixturenames and ARGV['coverage']: + i.add_marker('skip') + path = i.parent.fspath + if 'module_isolation' not in i.fixturenames: + tests[path] = None + continue + if path in tests and tests[path] is None: + continue + tests.setdefault(i.parent.fspath, []).append(i) + isolated_tests = sorted(k for k, v in tests.items() if v) + manager.set_isolated_modules(isolated_tests) + + if ARGV['update']: + isolated_tests = sorted(filter(manager.check_updated, tests)) + # if all tests will be skipped, do not launch the rpc client + if sorted(tests) == isolated_tests: + ARGV['norpc'] = True + # if update flag is active, add skip marker to unchanged tests + for path in isolated_tests: + tests[path][0].parent.add_marker('skip') + + def pytest_runtestloop(): + if not ARGV['norpc']: + brownie.network.connect(ARGV['network']) + + def pytest_runtest_protocol(item): + manager.set_active(item.parent.fspath) + + def pytest_report_teststatus(report): + return manager.check_status(report) + + def pytest_runtest_teardown(item, nextitem): + if list(item.parent.iter_markers('skip')): + return + # if this is the last test in a module, record the results + if not nextitem or item.parent.fspath != nextitem.parent.fspath: + manager.module_completed(item.parent.fspath) + + def pytest_sessionfinish(): + manager.save_json() + if ARGV['coverage']: + coverage_eval = brownie.test.coverage.get_merged() + output.print_coverage_totals(coverage_eval) + output.save_coverage_report( + coverage_eval, + Path(CONFIG['folders']['project']).joinpath("reports") + ) + if ARGV['gas']: + output.print_gas_profile() + brownie.project.close(False) + + def pytest_keyboard_interrupt(): + ARGV['interrupt'] = True + + # fixtures + @pytest.fixture(scope="module") + def module_isolation(): + brownie.rpc.reset() + yield + if not ARGV['interrupt']: + brownie.rpc.reset() + + @pytest.fixture + def fn_isolation(module_isolation): + brownie.rpc.snapshot() + yield + if not ARGV['interrupt']: + brownie.rpc.revert() + + @pytest.fixture(scope="session") + def a(): + yield brownie.accounts + + @pytest.fixture(scope="session") + def accounts(): + yield brownie.accounts + + @pytest.fixture(scope="session") + def history(): + yield brownie.history + + @pytest.fixture(scope="session") + def rpc(): + yield brownie.rpc + + @pytest.fixture(scope="session") + def web3(): + yield brownie.web3 + + @pytest.fixture + def no_call_coverage(): + ARGV['always_transact'] = False + yield + ARGV['always_transact'] = ARGV['coverage'] + + @pytest.fixture + def skip_coverage(): + pass diff --git a/brownie/types/__init__.py b/brownie/types/__init__.py deleted file mode 100644 index 11633cfe2..000000000 --- a/brownie/types/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/python3 - -from .types import * # noqa: F401 F403 diff --git a/brownie/types/types.py b/brownie/types/types.py deleted file mode 100644 index 89a0ca540..000000000 --- a/brownie/types/types.py +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/python3 - - -class StrictDict(dict): - '''Dict subclass that prevents adding new keys when locked''' - - _print_as_dict = True - - 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): - '''Locks the dict so that new keys cannot be added''' - for v in [i for i in self.values() if type(i) is StrictDict]: - v._lock() - self._locked = True - - def _unlock(self): - '''Unlocks the dict so that new keys can be added''' - for v in [i for i in self.values() if type(i) is StrictDict]: - v._unlock() - self._locked = False - - -class KwargTuple: - '''Tuple/dict hybrid container, used for return values on callable functions''' - - _print_as_list = True - - def __init__(self, values, abi): - 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] - - def __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): - '''KwargTuple.copy() -> a shallow copy of KwargTuple''' - return KwargTuple(self._tuple, self._abi) - - def count(self, value): - '''KwargTuple.count(value) -> integer -- return number of occurrences of value''' - return self._tuple.count(value) - - def dict(self): - '''KwargTuple.dict() -> a dictionary of KwargTuple's named items''' - return self._dict - - def index(self, value, *args): - '''KwargTuple.index(value, [start, [stop]]) -> integer -- return first index of value. - Raises ValueError if the value is not present.''' - return self._tuple.index(value, *args) - - def items(self): - '''KwargTuple.items() -> a set-like object providing a view on KwargTuple's named items''' - return self._dict.items() - - def keys(self): - '''KwargTuple.keys() -> a set-like object providing a view on KwargTuple's keys''' - return self._dict.values() - - -class FalseyDict(dict): - '''Dict subclass that returns None if a key is not present instead of raising''' - - _print_as_dict = True - - def __getitem__(self, key): - if key in self: - return super().__getitem__(key) - return None - - def _update_from_args(self, values): - '''Updates the dict from docopts.args''' - self.update(dict((k.lstrip("-"), v) for k, v in values.items())) - - def copy(self): - return FalseyDict(self) - - -class EventDict: - '''Dict/list hybrid container, base class for all events fired in a transaction.''' - - _print_as_dict = True - - def __init__(self, events): - '''Instantiates the class. - - Args: - events: event data as supplied by eth_event.decode_logs or eth_event.decode_trace''' - self._ordered = [_EventItem( - i['name'], - [dict((x['name'], x['value']) for x in i['data'])], - (pos,) - ) for pos, i in enumerate(events)] - self._dict = {} - for event in self._ordered: - if event.name in self._dict: - continue - events = [i for i in self._ordered if i.name == event.name] - self._dict[event.name] = _EventItem( - event.name, - events, - tuple(i.pos[0] for i in events) - ) - - def __bool__(self): - return bool(self._ordered) - - def __contains__(self, name): - '''returns True if an event fired with the given name.''' - return name in [i.name for i in self._ordered] - - def __getitem__(self, key): - '''if key is int: returns the n'th event that was fired - if key is str: returns a _EventItem dict of all events where name == key''' - if type(key) is int: - return self._ordered[key] - return self._dict[key] - - def __iter__(self): - return iter(self._ordered) - - def __len__(self): - '''returns the number of events that fired.''' - return len(self._ordered) - - def __str__(self): - return str(dict((k, [i[0] for i in v._ordered]) for k, v in self._dict.items())) - - def count(self, name): - '''EventDict.count(name) -> integer -- return number of occurrences of name''' - return len([i.name for i in self._ordered if i.name == name]) - - def items(self): - '''EventDict.items() -> a set-like object providing a view on EventDict's items''' - return self._dict.items() - - def keys(self): - '''EventDict.keys() -> a set-like object providing a view on EventDict's keys''' - return self._dict.keys() - - def values(self): - '''EventDict.values() -> an object providing a view on EventDict's values''' - return self._dict.values() - - -class _EventItem: - '''Dict/list hybrid container, represents one or more events with the same name - that were fired in a transaction. - - Attributes: - name: event name - pos: tuple of indexes where this event fired''' - - def __init__(self, name, event_data, pos): - self.name = name - self._ordered = event_data - self.pos = pos - if len(event_data) > 1: - self._print_as_list = True - else: - self._print_as_dict = True - - def __getitem__(self, key): - '''if key is int: returns the n'th event that was fired with this name - if key is str: returns the value of data field 'key' from the 1st event - within the container ''' - if type(key) is int: - return self._ordered[key] - return self._ordered[0][key] - - def __contains__(self, name): - '''returns True if this event contains a value with the given name.''' - return name in self._ordered[0] - - def __len__(self): - '''returns the number of events held in this container.''' - return len(self._ordered) - - def __str__(self): - if len(self._ordered) == 1: - return str(self._ordered[0]) - return str([i[0] for i in self._ordered]) - - def __iter__(self): - return iter(self._ordered) - - def __eq__(self, other): - return other == self._ordered - - def items(self): - '''_EventItem.items() -> a set-like object providing a view on _EventItem[0]'s items''' - return self._ordered[0].items() - - def keys(self): - '''_EventItem.keys() -> a set-like object providing a view on _EventItem[0]'s keys''' - return self._ordered[0].keys() - - def values(self): - '''_EventItem.values() -> an object providing a view on _EventItem[0]'s values''' - return self._ordered[0].values() - - -class _Singleton(type): - - _instances = {} - - def __call__(cls, *args, **kwargs): - if cls not in cls._instances: - cls._instances[cls] = super(_Singleton, cls).__call__(*args, **kwargs) - return cls._instances[cls] diff --git a/docs/api-brownie.rst b/docs/api-brownie.rst index cbca9c2d7..f322c9277 100644 --- a/docs/api-brownie.rst +++ b/docs/api-brownie.rst @@ -15,6 +15,203 @@ The ``brownie`` package is the main package containing all of Brownie's function >>> dir() ['Gui', 'accounts', 'alert', 'brownie', 'check', 'compile_source', 'config', 'history', 'network', 'project', 'rpc', 'web3', 'wei'] +.. _api-brownie-convert: + +``brownie.convert`` +=================== + +The ``convert`` module contains methods relating to data conversion. + +Formatting Contract Data +************************ + +The following methods are used to convert multiple values based on a contract ABI specification. Values are formatted via calls to the methods outlined under :ref:`type conversions`, and where appropriate :ref:`type classes` are applied. + +.. py:method:: brownie.convert.format_input(abi, inputs) + + Formats inputs based on a contract method ABI. + + * ``abi``: A contract method ABI as a dict. + * ``inputs``: List or tuple of values to format. + + Returns a list of values formatted for use by ``ContractTx`` or ``ContractCall``. + + Each value in ``inputs`` is converted using the one of the methods outlined in :ref:`type-conversions`. + + .. code-block:: python + + >>> from brownie.convert import format_input + >>> abi = {'constant': False, 'inputs': [{'name': '_to', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}], 'name': 'transfer', 'outputs': [{'name': '', 'type': 'bool'}], 'payable': False, 'stateMutability': 'nonpayable', 'type': 'function'} + >>> format_input(abi, ["0xB8c77482e45F1F44dE1745F52C74426C631bDD52","1 ether"]) + ['0xB8c77482e45F1F44dE1745F52C74426C631bDD52', 1000000000000000000] + +.. py:method:: brownie.convert.format_output(abi, outputs) + + Standardizes outputs from a contract call based on the contract's ABI. + + * ``abi``: A contract method ABI as a dict. + * ``outputs``: List or tuple of values to format. + + Each value in ``outputs`` is converted using the one of the methods outlined in :ref:`type-conversions`. + + This method is called internally by ``ContractCall`` to ensure that contract output formats remain consistent, regardless of the RPC client being used. + + .. code-block:: python + + >>> from brownie.convert import format_output + >>> abi = {'constant': True, 'inputs': [], 'name': 'name', 'outputs': [{'name': '', 'type': 'string'}], 'payable': False, 'stateMutability': 'view', 'type': 'function'} + >>> format_output(abi, ["0x5465737420546f6b656e"]) + ["Test Token"] + +.. py:method:: brownie.convert.format_event(event) + + Standardizes outputs from an event fired by a contract. + + * ``event``: Decoded event data as given by the ``decode_event`` or ``decode_trace`` methods of the `eth-event `__ package. + + The given event data is mutated in-place and returned. If an event topic is indexed, the type is changed to ``bytes32`` and ``" (indexed)"`` is appended to the name. + +.. _type-conversions: + +Type Conversions +**************** + +The following classes and methods are used to convert arguments supplied to ``ContractTx`` and ``ContractCall``. + + +.. py:method:: brownie.convert.to_uint(value, type_="uint256") + + Converts a value to an unsigned integer. This is equivalent to calling ``Wei`` and then applying checks for over/underflows. + +.. py:method:: brownie.convert.to_int(value, type_="int256") + + Converts a value to a signed integer. This is equivalent to calling ``Wei`` and then applying checks for over/underflows. + +.. py:method:: brownie.convert.to_bool(value) + + Converts a value to a boolean. Raises ``ValueError`` if the given value does not match a value in ``(True, False, 0, 1)``. + +.. py:method:: brownie.convert.to_address(value) + + Converts a value to a checksummed address. Raises ``ValueError`` if value cannot be converted. + +.. py:method:: brownie.convert.to_bytes(value, type_="bytes32") + + Converts a value to bytes. ``value`` can be given as bytes, a hex string, or an integer. + + Raises ``OverflowError`` if the length of the converted value exceeds that specified by ``type_``. + + Pads left with ``00`` if the length of the converted value is less than that specified by ``type_``. + + .. code-block:: python + + >>> to_bytes('0xff','bytes') + b'\xff' + >>> to_bytes('0xff','bytes16') + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff' + +.. py:method:: brownie.convert.to_string(value) + + Converts a value to a string. + +.. py:method:: brownie.convert.bytes_to_hex(value) + + Converts a bytes value to a hex string. + + .. code-block:: python + + >>> from brownie.convert import bytes_to_hex + >>> bytes_to_hex(b'\xff\x3a') + 0xff3a + >>> bytes_to_hex('FF') + 0xFF + >>> bytes_to_hex("Hello") + File "brownie/types/convert.py", line 149, in bytes_to_hex + raise ValueError("'{}' is not a valid hex string".format(value)) + ValueError: 'Hello' is not a valid hex string + +.. _type-classes: + +Type Classes +************ + +For certain types of contract data, Brownie uses subclasses to assist with conversion and comparison. + +.. _wei: + +.. py:class:: brownie.convert.Wei(value) + + Integer subclass that converts a value to wei and allows comparisons, addition and subtraction using the same conversion. + + ``Wei`` is useful for strings where you specify the unit, for large floats given in scientific notation, or where a direct conversion to ``int`` would cause inaccuracy from floating point errors. + + Whenever a Brownie method takes an input referring to an amount of ether, the given value is converted to ``Wei``. Balances and ``uint``/``int`` values returned in contract calls and events are given in ``Wei``. + + .. code-block:: python + + >>> from brownie import Wei + >>> Wei("1 ether") + 1000000000000000000 + >>> Wei("12.49 gwei") + 12490000000 + >>> Wei("0.029 shannon") + 29000000 + >>> Wei(8.38e32) + 838000000000000000000000000000000 + >>> Wei(1e18) == "1 ether" + True + >>> Wei("1 ether") < "2 ether" + True + >>> Wei("1 ether") - "0.75 ether" + 250000000000000000 + +.. py:class:: brownie.convert.EthAddress(value) + + String subclass for address comparisons. Raises a ``TypeError`` when compared to a non-address. + + Addresses returned from a contract call or as part of an event log are given in this type. + + .. code-block:: python + + >>> from brownie.convert import EthAddress + >>> e = EthAddress("0x0035424f91fd33084466f402d5d97f05f8e3b4af") + "0x0035424f91fd33084466f402d5d97f05f8e3b4af" + >>> e == "0x3506424F91fD33084466F402d5D97f05F8e3b4AF" + False + >>> e == "0x0035424F91fD33084466F402d5D97f05F8e3b4AF" + True + >>> e == "0x35424F91fD33084466F402d5D97f05F8e3b4AF" + Traceback (most recent call last): + File "brownie/convert.py", line 304, in _address_compare + raise TypeError(f"Invalid type for comparison: '{b}' is not a valid address") + TypeError: Invalid type for comparison: '0x35424F91fD33084466F402d5D97f05F8e3b4AF' is not a valid address + + >>> e == "potato" + Traceback (most recent call last): + File "brownie/convert.py", line 304, in _address_compare + raise TypeError(f"Invalid type for comparison: '{b}' is not a valid address") + TypeError: Invalid type for comparison: 'potato' is not a valid address + +.. py:class:: brownie.convert.HexString(value) + + String subclass for hexstring comparisons. Raises ``TypeError`` if compared to a non-hexstring. Evaluates ``True`` for hex strings with the same value but differing leading zeros or capitalization. + + All ``bytes`` values returned from a contract call or as part of an event log are given in this type. + + .. code-block:: python + + >>> h = HexString('0x00abcd') + "0xabcd" + >>> h == "0xabcd" + True + >>> h == "0x0000aBcD" + True + >>> h == "potato" + File "", line 1, in + File "brownie/convert.py", line 327, in _hex_compare + raise TypeError(f"Invalid type for comparison: '{b}' is not a valid hex string") + TypeError: Invalid type for comparison: 'potato' is not a valid hex string + ``brownie.exceptions`` ====================== @@ -84,17 +281,65 @@ types Raised when an invalid ABI is given while converting contract inputs or outputs. - - ``brownie._config`` =================== -The ``_config`` module handles all Brownie configuration settings. It is not designed to be accessed directly. If you wish to view or modify config settings while brownie is running, import ``brownie.config`` which will return a :ref:`api-types-strictdict` that contains all the settings: +The ``_config`` module handles all Brownie configuration settings. It is not designed to be accessed directly. If you wish to view or modify config settings while Brownie is running, import ``brownie.config`` which will return a ``ConfigDict`` with the active settings: .. code-block:: python >>> from brownie import config >>> type(config) - + >>> config['network_defaults'] {'name': 'development', 'gas_limit': False, 'gas_price': False} + +.. _api-types-configdict: + +ConfigDict +********** + +.. py:class:: brownie.types.types.ConfigDict + + Subclass of `dict `__ that prevents adding new keys when locked. Used to hold config file settings. + + .. code-block:: python + + >>> from brownie.types import ConfigDict + >>> s = ConfigDict({'test': 123}) + >>> s + {'test': 123} + +.. py:classmethod:: ConfigDict._lock + + Locks the ``ConfigDict``. When locked, attempts to add a new key will raise a ``KeyError``. + + .. code-block:: python + + >>> s._lock() + >>> s['other'] = True + Traceback (most recent call last): + File "brownie/types/types.py", line 18, in __setitem__ + raise KeyError("{} is not a known config setting".format(key)) + KeyError: 'other is not a known config setting' + >>> + +.. py:classmethod:: ConfigDict._unlock + + Unlocks the ``ConfigDict``. When unlocked, new keys can be added. + + .. code-block:: python + + >>> s._unlock() + >>> s['other'] = True + >>> s + {'test': 123, 'other': True} + +.. _api-types-singleton: + +``brownie._singleton`` +====================== + +.. py:class:: brownie.types.types._Singleton + +Internal metaclass used to create `singleton `__ objects. Instantiating a class derived from this metaclass will always return the same instance, regardless of how the child class was imported. diff --git a/docs/api-network.rst b/docs/api-network.rst index 8cbaeb60c..cc6f352db 100644 --- a/docs/api-network.rst +++ b/docs/api-network.rst @@ -190,12 +190,14 @@ Account Methods .. py:classmethod:: Account.balance() - Returns the current balance at the address, in wei as an integer. + Returns the current balance at the address, in :ref:`wei`. .. code-block:: python >>> accounts[0].balance() 100000000000000000000 + >>> accounts[0].balance() == "100 ether" + True .. py:classmethod:: Account.deploy(contract, *args, amount=None, gas_limit=None, gas_price=None, callback=None) @@ -203,9 +205,9 @@ Account Methods * ``contract``: A ``ContractContainer`` instance of the contract to be deployed. * ``*args``: Contract constructor arguments. - * ``amount``: Amount to send, in :ref:`wei `. - * ``gas_limit``: Gas limit, in :ref:`wei `. If none is given, the price is set using ``eth_estimateGas``. - * ``gas_price``: Gas price, in :ref:`wei `. If none is given, the price is set using ``eth_gasPrice``. + * ``amount``: Amount of ether to send with the transaction. The given value is converted to :ref:`wei `. + * ``gas_limit``: Gas limit for the transaction. The given value is converted to :ref:`wei `. If none is given, the price is set using ``eth_estimateGas``. + * ``gas_price``: Gas price for the transaction. The given value is converted to :ref:`wei `. If none is given, the price is set using ``eth_gasPrice``. Returns a ``Contract`` instance upon success. If the transaction reverts or you do not wait for a confirmation, a ``TransactionReceipt`` is returned instead. @@ -231,8 +233,10 @@ Account Methods Estimates the gas required to perform a transaction. Raises a ``VirtualMachineError`` if the transaction would revert. + The returned value is given as an ``int`` denominated in wei. + * ``to``: Recipient address. Can be an ``Account`` instance or string. - * ``amount``: Amount to send, in :ref:`wei `. + * ``amount``: Amount of ether to send. The given value is converted to :ref:`wei `. * ``data``: Transaction data hexstring. .. code-block:: python @@ -245,9 +249,9 @@ Account Methods Broadcasts a transaction from this account. * ``to``: Recipient address. Can be an ``Account`` instance or string. - * ``amount``: Amount to send, in :ref:`wei `. - * ``gas_limit``: Gas limit, in :ref:`wei `. If none is given, the price is set using ``eth_estimateGas``. - * ``gas_price``: Gas price, in :ref:`wei `. If none is given, the price is set using ``eth_gasPrice``. + * ``amount``: Amount of ether to send. The given value is converted to :ref:`wei `. + * ``gas_limit``: Gas limit for the transaction. The given value is converted to :ref:`wei `. If none is given, the price is set using ``eth_estimateGas``. + * ``gas_price``: Gas price for the transaction. The given value is converted to :ref:`wei `. If none is given, the price is set using ``eth_gasPrice``. * ``data``: Transaction data hexstring. Returns a ``TransactionReceipt`` instance. @@ -429,7 +433,7 @@ The ``contract`` module contains classes for interacting with smart contracts. Classes in this module are not meant to be instantiated directly. When a project is loaded, Brownie automatically creates ``ContractContainer`` instances from on the files in the ``contracts/`` folder. New ``Contract`` instances are created via methods in the container. -Arguments supplied to calls or transaction methods are converted using the methods outlined in :ref:`type-conversions`. +Arguments supplied to calls or transaction methods are converted using the methods outlined in the :ref:`convert` module. .. _api-network-contractcontainer: @@ -593,6 +597,8 @@ ContractContainer Methods >>> Token [] +.. _api-network-contract: + Contract -------- @@ -633,7 +639,7 @@ Contract Methods .. py:classmethod:: Contract.balance() - Returns the balance at the contract address, in wei at an int. + Returns the current balance at the contract address, in :ref:`wei`. .. code-block:: python @@ -651,6 +657,8 @@ ContractCall The expected inputs are shown in the method's ``__repr__`` value. + Inputs and return values are formatted via methods in the :ref:`convert` module. Multiple values are returned inside a :ref:`ReturnValue`. + .. code-block:: python >>> Token[0].allowance @@ -713,6 +721,10 @@ ContractTx Broadcasts a transaction to a potentially state-changing contract method. Returns a ``TransactionReceipt``. + The given ``args`` must match the required inputs for the method. The expected inputs are shown in the method's ``__repr__`` value. + + Inputs are formatted via methods in the :ref:`convert` module. + You can optionally include a dictionary of `transaction parameters `__ as the final argument. If you omit this or do not specify a ``'from'`` value, the transaction will be sent from the same address that deployed the contract. .. code-block:: python @@ -761,6 +773,8 @@ ContractTx Methods Calls the contract method without broadcasting a transaction, and returns the result. + Inputs and return values are formatted via methods in the :ref:`convert` module. Multiple values are returned inside a :ref:`ReturnValue`. + .. code-block:: python >>> Token[0].transfer.call(accounts[2], 10000, {'from': accounts[0]}) @@ -819,10 +833,170 @@ OverloadedMethod ``brownie.network.event`` ========================= -The ``event`` module contains methods related to decoding transaction event logs. It is largely a wrapper around `eth-event `__. +The ``event`` module contains classes and methods related to decoding transaction event logs. It is largely a wrapper around `eth-event `__. Brownie stores encrypted event topics in ``brownie/data/topics.json``. The JSON file is loaded when this module is imported. +.. _api-network-eventdict: + +EventDict +--------- + +.. py:class:: brownie.types.types.EventDict + + Hybrid container type that works as a `dict `__ and a `list `__. Base class, used to hold all events that are fired in a transaction. + + When accessing events inside the object: + + * If the key is given as an integer, events are handled as a list in the order that they fired. An ``_EventItem`` is returned for the specific event that fired at the given position. + * If the key is given as a string, a ``_EventItem`` is returned that contains all the events with the given name. + + .. code-block:: python + + >>> tx + + >>> tx.events + { + 'CountryModified': [ + { + 'country': 1, + 'limits': (0, 0, 0, 0, 0, 0, 0, 0), + 'minrating': 1, + 'permitted': True + }, + 'country': 2, + 'limits': (0, 0, 0, 0, 0, 0, 0, 0), + 'minrating': 1, + 'permitted': True + } + ], + 'MultiSigCallApproved': { + 'callHash': "0x0013ae2e37373648c5161d81ca78d84e599f6207ad689693d6e5938c3ae4031d", + 'caller': "0xf9c1fd2f0452fa1c60b15f29ca3250dfcb1081b9" + } + } + >>> tx.events['CountryModified'] + [ + { + 'country': 1, + 'limits': (0, 0, 0, 0, 0, 0, 0, 0), + 'minrating': 1, + 'permitted': True + }, + 'country': 2, + 'limits': (0, 0, 0, 0, 0, 0, 0, 0), + 'minrating': 1, + 'permitted': True + } + ] + >>> tx.events[0] + { + 'callHash': "0x0013ae2e37373648c5161d81ca78d84e599f6207ad689693d6e5938c3ae4031d", + 'caller': "0xf9c1fd2f0452fa1c60b15f29ca3250dfcb1081b9" + } + +.. py:classmethod:: EventDict.count(name) + + Returns the number of events that fired with the given name. + + .. code-block:: python + + >>> tx.events.count('CountryModified') + 2 + +.. py:classmethod:: EventDict.items + + Returns a set-like object providing a view on the object's items. + +.. py:classmethod:: EventDict.keys + + Returns a set-like object providing a view on the object's keys. + +.. py:classmethod:: EventDict.values + + Returns an object providing a view on the object's values. + +_EventItem +---------- + +.. py:class:: brownie.types.types._EventItem + + Hybrid container type that works as a `dict `__ and a `list `__. Represents one or more events with the same name that were fired in a transaction. + + Instances of this class are created by ``EventDict``, it is not intended to be instantiated directly. + + When accessing events inside the object: + + * If the key is given as an integer, events are handled as a list in the order that they fired. An ``_EventItem`` is returned for the specific event that fired at the given position. + * If the key is given as a string, ``_EventItem`` assumes that you wish to access the first event contained within the object. ``event['value']`` is equivalent to ``event[0]['value']``. + + All values within the object are formatted by methods outlined in the :ref:`convert` module. + + .. code-block:: python + + >>> event = tx.events['CountryModified'] + + >>> event + [ + { + 'country': 1, + 'limits': (0, 0, 0, 0, 0, 0, 0, 0), + 'minrating': 1, + 'permitted': True + }, + 'country': 2, + 'limits': (0, 0, 0, 0, 0, 0, 0, 0), + 'minrating': 1, + 'permitted': True + } + ] + >>> event[0] + { + 'country': 1, + 'limits': (0, 0, 0, 0, 0, 0, 0, 0), + 'minrating': 1, + 'permitted': True + } + >>> event['country'] + 1 + >>> event[1]['country'] + 2 + +.. py:attribute:: _EventItem.name + + The name of the event(s) contained within this object. + + .. code-block:: python + + >>> tx.events[2].name + CountryModified + + +.. py:attribute:: _EventItem.pos + + A tuple giving the absolute position of each event contained within this object. + + .. code-block:: python + + >>> event.pos + (1, 2) + >>> event[1].pos + (2,) + >>> tx.events[2] == event[1] + True + +.. py:classmethod:: _EventItem.items + + Returns a set-like object providing a view on the items in the first event within this object. + +.. py:classmethod:: _EventItem.keys + + Returns a set-like object providing a view on the keys in the first event within this object. + +.. py:classmethod:: _EventItem.values + + Returns an object providing a view on the values in the first event within this object. + Module Methods -------------- @@ -840,7 +1014,7 @@ Module Methods .. py:method:: brownie.network.event.decode_logs(logs) - Given an array of logs as returned by ``eth_getLogs`` or ``eth_getTransactionReceipt`` RPC calls, returns an :ref:`api-types-eventdict`. + Given an array of logs as returned by ``eth_getLogs`` or ``eth_getTransactionReceipt`` RPC calls, returns an :ref:`api-network-eventdict`. .. code-block:: python @@ -864,7 +1038,7 @@ Module Methods .. py:method:: brownie.network.event.decode_trace(trace) - Given the ``structLog`` from a ``debug_traceTransaction`` RPC call, returns an :ref:`api-types-eventdict`. + Given the ``structLog`` from a ``debug_traceTransaction`` RPC call, returns an :ref:`api-network-eventdict`. .. code-block:: python @@ -982,7 +1156,74 @@ _ContractHistory A :ref:`api-types-singleton` dict of ``OrderedDict`` instances, used internally by Brownie to track deployed contracts. - Under the hood, calls to get objects from ``ContractContainer`` instances are redirected to this class. The primary use case is to simplify deleting ``Contract`` instances after the local RPC is reset or reverted. + Under the hood, calls to get objects from ``ContractContainer`` instances are redirected to this class. The primary use case is to simplify deleting ``Contract`` instances after the local RPC is reset or reverted. + +.. _return_value: + +``brownie.network.return_value`` +================================ + +ReturnValue +----------- + +The ``return_value`` module contains the ``ReturnValue`` class, a container used when returning multiple values from a contract call. + +.. py:class:: brownie.network.return_value.ReturnValue + + Hybrid container type with similaries to both `tuple `__ and `dict `__. Used for contract return values. + + .. code-block:: python + + >>> result = issuer.getCountry(784) + >>> result + (1, (0, 0, 0, 0), (100, 0, 0, 0)) + >>> result[2] + (100, 0, 0, 0) + >>> result.dict() + { + '_count': (0, 0, 0, 0), + '_limit': (100, 0, 0, 0), + '_minRating': 1 + } + >>> result['_minRating'] + 1 + + When checking equality, ``ReturnValue`` objects ignore the type of container compared against. Tuples and lists will both return ``True`` so long as they contain the same values. + + .. code-block:: python + + >>> result = issuer.getCountry(784) + >>> result + (1, (0, 0, 0, 0), (100, 0, 0, 0)) + >>> result == (1, (0, 0, 0, 0), (100, 0, 0, 0)) + True + >>> result == [1, [0, 0, 0, 0], [100, 0, 0, 0]] + True + +.. py:classmethod:: ReturnValue.copy + + Returns a shallow copy of the object. + +.. py:classmethod:: ReturnValue.count(value) + + Returns the number of occurances of ``value`` within the object. + +.. py:classmethod:: ReturnValue.dict + + Returns a ``dict`` of the named values within the object. + +.. py:classmethod:: ReturnValue.index(value, [start, [stop]]) + + Returns the first index of ``value``. Raises ``ValueError`` if the value is not present. + +.. py:classmethod:: ReturnValue.items + + Returns a set-like object providing a view on the object's named items. + +.. py:classmethod:: ReturnValue.keys + + Returns a set-like object providing a view on the object's keys. + ``brownie.network.rpc`` ======================= @@ -1211,7 +1452,7 @@ TransactionReceipt Attributes .. py:attribute:: TransactionReceipt.events - An :ref:`api-types-eventdict` of decoded event logs for this transaction. + An :ref:`api-network-eventdict` of decoded event logs for this transaction. .. note:: If you are connected to an RPC client that allows for ``debug_traceTransaction``, event data is still available when the transaction reverts. @@ -1241,7 +1482,7 @@ TransactionReceipt Attributes .. py:attribute:: TransactionReceipt.gas_limit - The gas limit of the transaction, in wei as an int. + The gas limit of the transaction, in wei as an ``int``. .. code-block:: python @@ -1252,7 +1493,7 @@ TransactionReceipt Attributes .. py:attribute:: TransactionReceipt.gas_price - The gas price of the transaction, in wei as an int. + The gas price of the transaction, in wei as an ``int``. .. code-block:: python @@ -1263,7 +1504,7 @@ TransactionReceipt Attributes .. py:attribute:: TransactionReceipt.gas_used - The amount of gas consumed by the transaction, in wei as an int. + The amount of gas consumed by the transaction, in wei as an ``int``. .. code-block:: python @@ -1343,6 +1584,8 @@ TransactionReceipt Attributes The value returned from the called function, if any. Only available if the RPC client allows ``debug_traceTransaction``. + If more then one value was returned, they are stored in a :ref:`ReturnValue`. + .. code-block:: python >>> tx @@ -1374,7 +1617,7 @@ TransactionReceipt Attributes .. py:attribute:: TransactionReceipt.trace - The structLog from the `debug_traceTransaction `__ RPC method. If you are using Infura this attribute is not available. + An expanded `transaction trace `_ structLog, returned from the `debug_traceTransaction `__ RPC endpoint. If you are using Infura this attribute is not available. Along with the standard data, the structLog also contains the following additional information: @@ -1436,7 +1679,7 @@ TransactionReceipt Attributes .. py:attribute:: TransactionReceipt.value - The value of the transaction, in wei as an int. + The value of the transaction, in :ref:`wei`. .. code-block:: python @@ -1488,9 +1731,9 @@ TransactionReceipt Methods >>> tx.call_trace() Call trace for '0x0d96e8ceb555616fca79dd9d07971a9148295777bb767f9aa5b34ede483c9753': Token.transfer 0:244 (0x4A32104371b05837F2A36dF6D850FA33A92a178D) - ∟ Token.transfer 72:226 - ∟ SafeMath.sub 100:114 - ∟ SafeMath.add 149:165 + └─Token.transfer 72:226 + ├─SafeMath.sub 100:114 + └─SafeMath.add 149:165 .. py:classmethod:: TransactionReceipt.traceback() @@ -1568,6 +1811,8 @@ TransactionReceipt Methods The ``web3`` module contains a slightly modified version of the web3.py `Web3 `__ class that is used throughout various Brownie modules for RPC communication. +.. _web3: + Web3 ---- diff --git a/docs/api-project.rst b/docs/api-project.rst index 08e736a7b..453e8e14c 100644 --- a/docs/api-project.rst +++ b/docs/api-project.rst @@ -43,7 +43,7 @@ Package Methods .. py:method:: main.pull(project_name, project_path=None, ignore_subfolder=False) - Initializes a new project via a template. Templates are downloaded from the `Brownie Mix github repo `_. + Initializes a new project via a template. Templates are downloaded from the `Brownie Mix github repo `_. If no path is given, the project will be initialized in a subfolder of the same name. @@ -321,6 +321,44 @@ These are more low-level methods, called internally during the execution of the >>> expand_source_map("1:2:1:-;:9;2:1:2;;;") [[1, 2, 1, '-'], [1, 9, 1, '-'], [2, 1, 2, '-'], [2, 1, 2, '-'], [2, 1, 2, '-'], [2, 1, 2, '-']] +``brownie.project.scripts`` +=========================== + +The ``scripts`` module contains methods for comparing, importing and executing python scripts related to a project. + +.. py:method:: scripts.run(script_path, method_name="main", args=None, kwargs=None, gas_profile=False) + + Imports a project script, runs a method in it and returns the result. + + ``script_path``: path of script to import + ``method_name``: name of method in the script to run + ``args``: method args + ``kwargs``: method kwargs + ``gas_profile``: if ``True``, gas use data will be displayed when the script completes + + .. code-block:: python + + >>> from brownie import run + >>> run('token') + + Running 'scripts.token.main'... + + Transaction sent: 0xeb9dfb6d97e8647f824a3031bc22a3e523d03e2b94674c0a8ee9b3ff601f967b + Token.constructor confirmed - block: 1 gas used: 627391 (100.00%) + Token deployed at: 0x8dc446C44C821F27B333C1357990821E07189E35 + + +.. py:method:: scripts.get_ast_hash(path) + + Returns a hash based on the AST of a script and any scripts that it imports. Used to determine if a project script has been altered since it was last run. + + ``path``: path of the script + + .. code-block:: python + + >>> from brownie.project.scripts import get_ast_hash + >>> get_ast_hash('scripts/deploy.py') + '12b57e7bb8d88e3f289e27ba29e5cc28eb110e45' ``brownie.project.sources`` =========================== diff --git a/docs/api-test.rst b/docs/api-test.rst index a320b8688..5bf56aa89 100644 --- a/docs/api-test.rst +++ b/docs/api-test.rst @@ -6,380 +6,149 @@ Test API The ``test`` package contains classes and methods for running tests and evaluating test coverage. -This functionality is typically accessed via the command line interface. See :ref:`test` and :ref:`coverage`. +This functionality is typically accessed via `pytest `_. See :ref:`test`. -``brownie.test.main`` -===================== -The ``main`` module contains higher level methods for executing a project's tests. These methods are available directly from ``brownie.test``. - -.. py:method:: test.run_tests(test_path, only_update=True, check_coverage=False, gas_profile=False) - - Locates and executes tests for a project. Calling this method is equivalent to running ``brownie test`` from the CLI. - - * ``test_path``: path to locate tests in - * ``only_update``: if ``True``, will only run tests that were not previous run or where changes to related files have occured - * ``check_coverage``: if ``True``, test coverage will also be evaluated and a report shown in the console - * ``gas_profile``: if ``True``, gas use data will be shown in the console - -.. py:method:: test.run_script(script_path, method_name="main", args=(), kwargs={}, gas_profile=False) - - Loads a script and calls a method within it. Calling this method is equivalent to calling ``brownie run`` from the CLI. - - * ``script_path``: path of script to load - * ``method_name``: name of method to call - * ``args``: method args - * ``kwargs``: method keyword arguments - * ``gas_profile``: if ``True``, gas use data will be shown in the console - -.. _api_check: - -``brownie.test.check`` -====================== - -The ``check`` module exposes the following methods that are used in place of ``assert`` when writing Brownie tests. All check methods raise an ``AssertionError`` when they fail. - -.. py:method:: check.true(statement, fail_msg = "Expected statement to be True") - - Raises if ``statement`` is not ``True``. - - .. code-block:: python - - >>> check.true(True) - >>> check.true(2 + 2 == 4) - >>> - >>> check.true(0 > 1) - File "brownie/test/check.py", line 18, in true - raise AssertionError(fail_msg) - AssertionError: Expected statement to be True - - >>> check.true(False, "What did you expect?") - File "brownie/test/check.py", line 18, in true - raise AssertionError(fail_msg) - AssertionError: What did you expect? - - >>> check.true(1) - File "brownie/test/check.py", line 16, in true - raise AssertionError(fail_msg+" (evaluated truthfully but not True)") - AssertionError: Expected statement to be True (evaluated truthfully but not True) - -.. py:method:: check.false(statement, fail_msg = "Expected statement to be False") - - Raises if ``statement`` is not ``False``. - - .. code-block:: python - - >>> check.false(0 > 1) - >>> check.false(2 + 2 == 4) - File "brownie/test/check.py", line 18, in false - raise AssertionError(fail_msg) - AssertionError: Expected statement to be False - - >>> check.false(0) - File "brownie/test/check.py", line 16, in false - raise AssertionError(fail_msg+" (evaluated falsely but not False)") - AssertionError: Expected statement to be False (evaluated falsely but not False) - -.. py:method:: check.confirms(fn, args, fail_msg = "Expected transaction to confirm") - - Performs the given contract call ``fn`` with arguments ``args``. Raises if the call causes the EVM to revert. - - Returns a ``TransactionReceipt`` instance. - - .. code-block:: python - - >>> Token[0].balanceOf(accounts[2]) - 900 - >>> check.confirms(Token[0].transfer, (accounts[0], 900, {'from': accounts[2]})) - - Transaction sent: 0xc9e056550ec579ba6b842d27bb7f029912c865becce19ee077734a04d5198f8c - Token.transfer confirmed - block: 7 gas used: 20921 (15.39%) - - >>> Token[0].balanceOf(accounts[2]) - 0 - >>> check.confirms(Token[0].transfer, (accounts[0], 900, {'from': accounts[2]})) - File "brownie/test/check.py", line 61, in confirms - raise AssertionError(fail_msg) - AssertionError: Expected transaction to confirm - -.. py:method:: check.reverts(fn, args, revert_msg=None) - - Performs the given contract call ``fn`` with arguments ``args``. Raises if the call does not cause the EVM to revert. This check will work regardless of if the revert happens from a call or a transaction. - - .. code-block:: python - - >>> Token[0].balanceOf(accounts[2]) - 900 - >>> check.reverts(Token[0].transfer, (accounts[0], 10000, {'from': accounts[2]}) - >>> check.reverts(Token[0].transfer, (accounts[0], 900, {'from': accounts[2]})) - - Transaction sent: 0xc9e056550ec579ba6b842d27bb7f029912c865becce19ee077734a04d5198f8c - Token.transfer confirmed - block: 7 gas used: 20921 (15.39%) - File "brownie/test/check.py", line 45, in reverts - raise AssertionError(fail_msg) - AssertionError: Expected transaction to revert - -.. py:method:: check.event_fired(tx, name, count=None, values=None) - - Expects a transaction to contain an event. - - * ``tx``: A ``TransactionReceipt`` instance. - * ``name``: Name of the event that must fire. - * ``count``: Number of times the event must fire. If left as ``None``, the event must fire one or more times. - * ``values``: A dict, or list of dicts, speficying key:value pairs that must be found within the events of the given name. The length of the ``values`` implies the number of events that must fire with that name. - - .. code-block:: python - - >>> tx = Token[0].transfer(accounts[1], 1000, {'from': accounts[0]}) - - Transaction sent: 0xaf9f68a8e72764f7475263aeb11ae544d81e45516787b93cc8797b7152195a52 - Token.transfer confirmed - block: 3 gas used: 35985 (26.46%) - - >>> check.event_fired(tx, "Transfer") - >>> check.event_fired(tx, "Transfer", count=1) - >>> check.event_fired(tx, "Transfer", count=2) - File "brownie/test/check.py", line 80, in event_fired - name, count, len(events) - AssertionError: Event Transfer - expected 2 events to fire, got 1 - >>> - >>> check.event_fired(tx, "Transfer", values={'value': 1000}) - >>> check.event_fired(tx, "Transfer", values={'value': 2000}) - File "brownie/test/check.py", line 105, in event_fired - name, k, v, data[k] - AssertionError: Event Transfer - expected value to equal 2000, got 1000 - >>> - >>> check.event_fired(tx, "Transfer", values=[{'value': 1000}, {'value': 2000}]) - File "brownie/test/check.py", line 91, in event_fired - name, len(events), len(values) - AssertionError: Event Transfer - 1 events fired, 2 values to match given - -.. py:method:: check.event_not_fired(tx, name, fail_msg="Expected event not to fire") - - Expects a transaction not to contain an event. - - * ``tx``: A ``TransactionReceipt`` instance. - * ``name``: Name of the event that must fire. - * ``fail_msg``: Message to show if check fails. - - .. code-block:: python - - >>> tx = Token[0].transfer(accounts[1], 1000, {'from': accounts[0]}) - - Transaction sent: 0xaf9f68a8e72764f7475263aeb11ae544d81e45516787b93cc8797b7152195a52 - Token.transfer confirmed - block: 3 gas used: 35985 (26.46%) - - >>> check.event_not_fired(tx, "Approve") - >>> check.event_not_fired(tx, "Transfer") - File "brownie/test/check.py", line 80, in event_not_fired - name, count, len(events) - AssertionError: Expected event not to fire - -.. py:method:: check.equal(a, b, fail_msg = "Expected values to be equal", strict=False) +``brownie.test.plugin`` +======================= - Raises if ``a != b``. +The ``plugin`` module contains classes and methods used in the Brownie Pytest plugin. It defines custom fixtures and handles integration into the Pytest workflow. - Different types of sequence objects will still evaluate equally as long as their content is the same: ``(1,1,1) == [1,1,1]``. +Pytest Fixtures +--------------- - When ``strict`` is set to ``False`` the following will evaluate as equal: +Brownie includes the following fixtures for use with ``pytest``. - * hexstrings of the same value but differing leading zeros: ``0x00001234 == 0x1234`` - * integers, floats, and strings as :ref:`wei ` that have the same numberic value: ``1 == 1.0 == "1 wei"`` +.. note:: These fixtures are only available when pytest is run from inside a Brownie project folder. - .. code-block:: python - >>> t = Token[0] - - >>> t.balanceOf(accounts[0]) - 10000 - >>> t.balanceOf(accounts[1]) - 0 - >>> check.equal(t.balanceOf(accounts[0]), t.balanceOf(accounts[1])) - File "brownie/test/check.py", line 74, in equal - raise AssertionError(fail_msg) - AssertionError: Expected values to be equal +Session Fixtures +**************** -.. py:method:: check.not_equal(a, b, fail_msg = "Expected values to be not equal", strict=False) +These fixtures provide access to objects related to the project being tested. - Raises if ``a == b``. Comparison rules are the same as ``check.equal``. +.. py:attribute:: plugin.accounts - .. code-block:: python + Session scope. Yields an instantiated :ref:`Accounts` container for the active project. - >>> t = Token[0] - - >>> t.balanceOf(accounts[1]) - 0 - >>> t.balanceOf(accounts[2]) - 0 - >>> check.not_equal(t.balanceOf(accounts[1]), t.balanceOf(accounts[2])) - File "brownie/test/check.py", line 86, in not_equal - raise AssertionError(fail_msg) - AssertionError: Expected values to be not equal +.. py:attribute:: plugin.a -``brownie.test.coverage`` -========================= + Session scope. Short form of the ``accounts`` fixture. -The ``coverage`` module contains methods related to test coverage analysis. +.. py:attribute:: plugin.history -.. py:method:: coverage.analyze(history, coverage_eval={}) + Session scope. Yields an instantiated :ref:`TxHistory` object for the active project. - Analyzes contract coverage. +.. py:attribute:: plugin.rpc - * ``history``: List of ``TransactionReceipt`` objects. - * ``coverage_eval``: Coverage evaluation data from a previous call to this method. If given, the results will of this call will be merged into it. + Session scope. Yields an instantiated :ref:`Rpc` object. - Returns a coverage evaluation map, structured as follows. The ``index`` values are the same as the coverage indexes in the `program counter map `_. Whenever an index is encountered during the transaction trace it is added to the coverage map. +.. py:attribute:: plugin.web3 - .. code-block:: javascript + Session scope. Yields an instantiated :ref:`Web3` object. - { - "ContractName": { - "statements": { - "path/to/file": {index, index, .. }, .. - }, - "branches": { - "true": { - "path/to/file": {index, index, ..}, .. - }, - "false": { - "path/to/file": {index, index, ..}, .. - } - } - } - } +Isolation Fixtures +****************** -.. py:method:: coverage.merge(coverage_eval_list) +These fixtures are used to effectively isolate tests. If included on every test within a module, that module may now be skipped via the ``--update`` flag when none of the related files have changed since it was last run. - Given a list of coverage evaluation maps, returns a single merged coverage evaluation map. +.. py:attribute:: plugin.module_isolation -.. py:method:: coverage.merge_files(coverage_files) + Module scope. When used, this fixture is always applied before any other module-scoped fixtures. - Given a list of coverage evaluation json file paths, returns a single merged coverage evaluation map. + Resets the local environment before starting the first test and again after completing the final test. -.. py:method:: coverage.split_by_fn(coverage_eval) +.. py:method:: plugin.fn_isolation(module_isolation) - Splits a coverage evaluation map by contract function. + Function scope. When used, this fixture is always applied before any other function-scoped fixtures. -.. py:method:: coverage.get_totals(coverage_eval) + Applies the ``module_isolation`` fixture, and additionally takes a snapshot prior to running each test which is then reverted to after the test completes. The snapshot is taken immediately after any module-scoped fixtures are applied, and before all function-scoped ones. - Returns a modified coverage eval dict showing counts and totals for each - contract function. +Coverage Fixtures +***************** -.. py:method:: coverage.get_highlights(coverage_eval) +These fixtures alter the behaviour of tests when coverage evaluation is active. - Given a coverage evaluation map as generated by ``coverage.analyze`` or ``coverage.merge``, returns a generic highlight report suitable for display within the Brownie GUI. +.. py:attribute:: plugin.no_call_coverage -``brownie.test.executor`` -========================= + Function scope. Coverage evaluation will not be performed on called contact methods during this test. -The ``executor`` module contains methods used for executing test modules. It is called internally by ``main.run_tests``. +.. py:attribute:: plugin.skip_coverage -.. py:method:: executor.run_test_modules(test_paths, only_update=True, check_coverage=False, save=True) + Function scope. If coverage evaluation is active, this test will be skipped. - Runs tests across one or more modules. +RevertContextManager +-------------------- - * ``test_paths``: list of test module paths - * ``only_update``: if ``True``, will only run tests that were not previous run or where changes to related files have occured - * ``check_coverage``: if ``True``, test coverage will also be evaluated and a report shown in the console - * ``save``: if ``True``, test results will be saved in the ``build/tests`` folder +The ``RevertContextManager`` closely mimics the behaviour of `pytest.raises `_. -``brownie.test.loader`` -======================= +.. py:class:: plugin.RevertContextManager(revert_msg=None) -The ``loader`` module contains methods used internally for preparing and importing test modules. + Context manager used to handle ``VirtualMachineError`` exceptions. Raises ``AssertionError`` if no transaction has reverted when the context closes. -.. py:method:: loader.import_from_path(path) + * ``revert_msg``: Optional. Raises an ``AssertionError`` if the transaction does not revert with this error string. - Imports a module from the given path. + Available as ``pytest.reverts``. .. code-block:: python + :linenos: - >>> from brownie.test.loader import import_from_path - >>> import_from_path('scripts/token.py') - + import pytest + from brownie import accounts -.. py:method:: loader.get_methods(path, coverage=False) + def test_transfer_reverts(Token, accounts): + token = accounts[0].deploy(Token, "Test Token", "TST", 18, "1000 ether") + with pytest.reverts(): + token.transfer(account[2], "10000 ether", {'from': accounts[1]}) - Parses a module and returns information about the methods it contains. Used internally by ``executor.run_test_modules``. +``brownie.test.manager`` +======================== - Returns a list of two item tuples. The first item is the method, the second is a `FalseyDict `_ of method settings extracted from its keyword arguments. +The ``manager`` module contains the ``TestManager`` class, used internally by Brownie to determine which tests should run and to load and save the test results. ``brownie.test.output`` ======================= -The ``output`` module contains classes and methods for formatting and printing test output to the console. - -TestPrinter ------------ - -The ``TestPrinter`` class is used by ``executor.run_test_modules`` for outputting test results. +The ``output`` module contains methods for formatting and displaying test output. Module Methods -------------- -.. py:method:: output.coverage_totals(coverage_eval) - - Formats and prints a coverage evaluation report to the console. - -.. py:method:: output.gas_profile() - - Prints a formatted version of `TxHistory.gas_profile `_ to the console. +.. py:method:: output.save_coverage_report(coverage_eval, report_path) + Generates and saves a test coverage report for viewing in the GUI. + * ``coverage_eval``: Coverage evaluation dict + * ``report_path``: Path to save to. If the path is a folder, the report is saved as ``coverage-%d%m%y.json``. -``brownie.test.pathutils`` -========================== - -The ``pathutils`` module contains methods for working with paths related to test and script execution, and test result JSON files. - -.. py:method:: pathutils.check_build_hashes(base_path) - - Checks the hash data in all test build json files, and deletes those where - hashes have changed. - -.. py:method:: pathutils.remove_empty_folders(base_path) - - Removes empty subfolders within the given path. - -.. py:method:: pathutils.get_ast_hash(path) - - Generates a sha1 hash based on the AST of a script. Any projectscripts that are imported will also be included when generating the hash. - - Used to check if the functionality within a test has changed, when determining if it should be re-run. - -.. py:method:: pathutils.get_path(path_str, default_folder="scripts") - - Returns a Path object for a python module. Used for finding a user-specified script. +.. py:method:: output.print_gas_profile() - * ``path_str``: Path to the script. Raises ``FileNotFoundError`` if the given path is a folder. - * ``default_folder``: default folder path to check if ``path_str`` is not found. + Formats and prints a gas profile report. -.. py:method:: pathutils.get_paths(path_str=None, default_folder="tests") +.. py:method:: output.print_coverage_totals(coverage_eval) - Returns a list of Path objects of python modules. Used for finding a test scripts based on the given path. + Formats and prints a coverage evaluation report. - * ``path_str``: Base path to look for modules in. If given path is a folder, all scripts within the folder and it's subfolders will be returned. - * ``default_folder``: default folder path to check if ``path_str`` is not found. + * ``coverage_eval``: Coverage evaluation dict -.. py:method:: pathutils.get_build_paths(test_paths) +``brownie.test.coverage`` +========================= - Given a list of test paths, returns an equivalent list of build paths. +The ``coverage`` module is used internally for storing and accessing coverage evaluation data. -.. py:method:: pathutils.get_build_json(test_path) +Module Methods +-------------- - Loads the data for a given test that has been saved in the ``build/tests`` folder. If the file cannot be found or is corrupted, creates the necessary folder structure and returns an appropriately formatted blank dict. +.. py:method:: coverage.add(txhash, coverage_eval) -.. py:method:: pathutils.save_build_json(module_path, result, coverage_eval, contract_names) +.. py:method:: coverage.add_cached(txhash, coverage_eval) - Saves the result data for a given test as a JSON in the ``build/tests`` folder. +.. py:method:: coverage.add_from_cached(txhash, active=True) - * ``module_path``: Path of the test module - * ``result``: Result of the test execution (``"passing"`` or ``"failing"``) - * ``coverage_eval``: Test coverage evaluation as a dict - * ``contract_names``: List of contracts called by the test module during it's execution +.. py:method:: coverage.get_and_clear_active() -.. py:method:: pathutils.save_report(coverage_eval, report_path) +.. py:method:: coverage.get_all() - Saves a test coverage report for viewing in the GUI. +.. py:method:: coverage.get_merged() - * ``coverage_eval``: Coverage evaluation dict - * ``report_path``: Path to save to. If the path is a folder, the report is saved as ``coverage-%d%m%y.json``. +.. py:method:: coverage.clear() diff --git a/docs/api-types.rst b/docs/api-types.rst deleted file mode 100644 index 146e730fd..000000000 --- a/docs/api-types.rst +++ /dev/null @@ -1,405 +0,0 @@ -.. _api-types: - -========= -Types API -========= - -``brownie.types`` -================= - -The ``types`` package contains methods relating to data conversion, as well as data types that are unique to Brownie. - -``brownie.types.convert`` -========================= - -The ``convert`` module contains methods relating to data conversion. - -Formatting Contract Arguments ------------------------------ - -.. py:method:: brownie.types.convert.format_input(abi, inputs) - - Formats inputs based on a contract method ABI. - - * ``abi``: A contract method ABI as a dict. - * ``inputs``: List or tuple of values to format. - - Returns a list of values formatted for use by ``ContractTx`` or ``ContractCall``. - - Each value in ``inputs`` is converted using the one of the methods outlined in :ref:`type-conversions`. - - .. code-block:: python - - >>> from brownie.types.convert import format_input - >>> abi = {'constant': False, 'inputs': [{'name': '_to', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}], 'name': 'transfer', 'outputs': [{'name': '', 'type': 'bool'}], 'payable': False, 'stateMutability': 'nonpayable', 'type': 'function'} - >>> format_input(abi, ["0xB8c77482e45F1F44dE1745F52C74426C631bDD52","1 ether"]) - ['0xB8c77482e45F1F44dE1745F52C74426C631bDD52', 1000000000000000000] - -.. py:method:: brownie.types.convert.format_output(abi, outputs) - - Standardizes outputs from a contract call based on the contract's ABI. - - * ``abi``: A contract method ABI as a dict. - * ``outputs``: List or tuple of values to format. - - Each value in ``outputs`` is converted using the one of the methods outlined in :ref:`type-conversions`. - - This method is called internally by ``ContractCall`` to ensure that contract output formats remain consistent, regardless of the RPC client being used. - - .. code-block:: python - - >>> from brownie.types.convert import format_output - >>> abi = {'constant': True, 'inputs': [], 'name': 'name', 'outputs': [{'name': '', 'type': 'string'}], 'payable': False, 'stateMutability': 'view', 'type': 'function'} - >>> format_output(abi, ["0x5465737420546f6b656e"]) - ["Test Token"] - -.. _type-conversions: - -Type Conversions ----------------- - -The following methods are used to convert arguments supplied to ``ContractTx`` and ``ContractCall``. - -.. _wei: - -.. py:method:: brownie.types.convert.wei(value) - - Converts a value to an integer in wei. Useful for strings where you specify the unit, or for large floats given in scientific notation, where a direct conversion to ``int`` would cause inaccuracy from floating point errors. - - ``wei`` is automatically applied in all Brownie methods when an input is meant to specify an amount of ether. - - .. code-block:: python - - >>> from brownie import wei - >>> wei("1 ether") - 1000000000000000000 - >>> wei("12.49 gwei") - 12490000000 - >>> wei("0.029 shannon") - 29000000 - >>> wei(8.38e32) - 838000000000000000000000000000000 - -.. py:method:: brownie.types.convert.to_uint(value, type_="uint256") - - Converts a value to an unsigned integer. This is equivalent to calling ``wei`` and then applying checks for over/underflows. - -.. py:method:: brownie.types.convert.to_int(value, type_="int256") - - Converts a value to a signed integer. This is equivalent to calling ``wei`` and then applying checks for over/underflows. - -.. py:method:: brownie.types.convert.to_bool(value) - - Converts a value to a boolean. Raises ``ValueError`` if the given value does not match a value in ``(True, False, 0, 1)``. - -.. py:method:: brownie.types.convert.to_address(value) - - Converts a value to a checksummed address. Raises ``ValueError`` if value cannot be converted. - -.. py:method:: brownie.types.convert.to_bytes(value, type_="bytes32") - - Converts a value to bytes. ``value`` can be given as bytes, a hex string, or an integer. - - Raises ``OverflowError`` if the length of the converted value exceeds that specified by ``type_``. - - Pads left with ``00`` if the length of the converted value is less than that specified by ``type_``. - - .. code-block:: python - - >>> to_bytes('0xff','bytes') - b'\xff' - >>> to_bytes('0xff','bytes16') - b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff' - -.. py:method:: brownie.types.convert.to_string(value) - - Converts a value to a string. - -.. py:method:: brownie.types.convert.bytes_to_hex(value) - - Converts a bytes value to a hex string. - - .. code-block:: python - - >>> from brownie.types.convert import bytes_to_hex - >>> bytes_to_hex(b'\xff\x3a') - 0xff3a - >>> bytes_to_hex('FF') - 0xFF - >>> bytes_to_hex("Hello") - File "brownie/types/convert.py", line 149, in bytes_to_hex - raise ValueError("'{}' is not a valid hex string".format(value)) - ValueError: 'Hello' is not a valid hex string - -``brownie.types.types`` -======================= - -The ``types`` module contains data types that are unique to Brownie. - -.. _api-types-strictdict: - -StrictDict ----------- - -.. py:class:: brownie.types.types.StrictDict - - Subclass of `dict `__ that prevents adding new keys when locked. Used to hold config file settings. - - .. code-block:: python - - >>> from brownie.types import StrictDict - >>> s = StrictDict({'test': 123}) - >>> s - {'test': 123} - -.. py:classmethod:: StrictDict._lock - - Locks the ``StrictDict``. When locked, attempts to add a new key will raise a ``KeyError``. - - .. code-block:: python - - >>> s._lock() - >>> s['other'] = True - Traceback (most recent call last): - File "brownie/types/types.py", line 18, in __setitem__ - raise KeyError("{} is not a known config setting".format(key)) - KeyError: 'other is not a known config setting' - >>> - -.. py:classmethod:: StrictDict._unlock - - Unlocks the ``StrictDict``. When unlocked, new keys can be added. - - .. code-block:: python - - >>> s._unlock() - >>> s['other'] = True - >>> s - {'test': 123, 'other': True} - -... _api-types-falseydict: - -FalseyDict ----------- - -.. py:class:: brownie.types.types.FalseyDict - - Subclass of `dict `__ that returns ``False`` if a key is not present. Used by ``brownie._config`` for command-line flags. - -.. py:classmethod:: FalseyDict._update_from_args(values) - - Parses command line arguments as returned from `docopt(__doc__) `__ and adds them to the object. - -KwargTuple ----------- - -.. py:class:: brownie.types.types.KwargTuple - - Hybrid container type with similaries to both `tuple `__ and `dict `__. Used for contract return values. - - .. code-block:: python - - >>> k = issuer.getCountry(784) - >>> k - (1, (0, 0, 0, 0, 0, 0, 0, 0), (100, 0, 0, 0, 0, 0, 0, 0)) - >>> k[2] - (100, 0, 0, 0, 0, 0, 0, 0) - >>> k.dict() - { - '_count': (0, 0, 0, 0, 0, 0, 0, 0), - '_limit': (100, 0, 0, 0, 0, 0, 0, 0), - '_minRating': 1 - } - >>> k['_minRating'] - 1 - -.. py:classmethod:: KwargTuple.copy - - Returns a shallow copy of the object. - -.. py:classmethod:: KwargTuple.count(value) - - Returns the number of occurances of ``value`` within the object. - -.. py:classmethod:: KwargTuple.dict - - Returns a ``dict`` of the named values within the object. - -.. py:classmethod:: KwargTuple.index(value, [start, [stop]]) - - Returns the first index of ``value``. Raises ``ValueError`` if the value is not present. - -.. py:classmethod:: KwargTuple.items - - Returns a set-like object providing a view on the object's named items. - -.. py:classmethod:: KwargTuple.keys - - Returns a set-like object providing a view on the object's keys. - -.. _api-types-eventdict: - -EventDict ---------- - -.. py:class:: brownie.types.types.EventDict - - Hybrid container type that works as a `dict `__ and a `list `__. Base class, used to hold all events that are fired in a transaction. - - When accessing events inside the object: - - * If the key is given as an integer, events are handled as a list in the order that they fired. An ``_EventItem`` is returned for the specific event that fired at the given position. - * If the key is given as a string, a ``_EventItem`` is returned that contains all the events with the given name. - - .. code-block:: python - - >>> tx - - >>> tx.events - { - 'CountryModified': [ - { - 'country': 1, - 'limits': (0, 0, 0, 0, 0, 0, 0, 0), - 'minrating': 1, - 'permitted': True - }, - 'country': 2, - 'limits': (0, 0, 0, 0, 0, 0, 0, 0), - 'minrating': 1, - 'permitted': True - } - ], - 'MultiSigCallApproved': { - 'callHash': "0x0013ae2e37373648c5161d81ca78d84e599f6207ad689693d6e5938c3ae4031d", - 'caller': "0xf9c1fd2f0452fa1c60b15f29ca3250dfcb1081b9" - } - } - >>> tx.events['CountryModified'] - [ - { - 'country': 1, - 'limits': (0, 0, 0, 0, 0, 0, 0, 0), - 'minrating': 1, - 'permitted': True - }, - 'country': 2, - 'limits': (0, 0, 0, 0, 0, 0, 0, 0), - 'minrating': 1, - 'permitted': True - } - ] - >>> tx.events[0] - { - 'callHash': "0x0013ae2e37373648c5161d81ca78d84e599f6207ad689693d6e5938c3ae4031d", - 'caller': "0xf9c1fd2f0452fa1c60b15f29ca3250dfcb1081b9" - } - -.. py:classmethod:: EventDict.count(name) - - Returns the number of events that fired with the given name. - - .. code-block:: python - - >>> tx.events.count('CountryModified') - 2 - -.. py:classmethod:: EventDict.items - - Returns a set-like object providing a view on the object's items. - -.. py:classmethod:: EventDict.keys - - Returns a set-like object providing a view on the object's keys. - -.. py:classmethod:: EventDict.values - - Returns an object providing a view on the object's values. - -_EventItem ----------- - -.. py:class:: brownie.types.types._EventItem - - Hybrid container type that works as a `dict `__ and a `list `__. Represents one or more events with the same name that were fired in a transaction. - - Instances of this class are created by ``EventDict``, it is not intended to be instantiated directly. - - When accessing events inside the object: - - * If the key is given as an integer, events are handled as a list in the order that they fired. An ``_EventItem`` is returned for the specific event that fired at the given position. - * If the key is given as a string, ``_EventItem`` assumes that you wish to access the first event contained within the object. ``event['value']`` is equivalent to ``event[0]['value']``. - - .. code-block:: python - - >>> event = tx.events['CountryModified'] - - >>> event - [ - { - 'country': 1, - 'limits': (0, 0, 0, 0, 0, 0, 0, 0), - 'minrating': 1, - 'permitted': True - }, - 'country': 2, - 'limits': (0, 0, 0, 0, 0, 0, 0, 0), - 'minrating': 1, - 'permitted': True - } - ] - >>> event[0] - { - 'country': 1, - 'limits': (0, 0, 0, 0, 0, 0, 0, 0), - 'minrating': 1, - 'permitted': True - } - >>> event['country'] - 1 - >>> event[1]['country'] - 2 - -.. py:attribute:: _EventItem.name - - The name of the event(s) contained within this object. - - .. code-block:: python - - >>> tx.events[2].name - CountryModified - - -.. py:attribute:: _EventItem.pos - - A tuple giving the absolute position of each event contained within this object. - - .. code-block:: python - - >>> event.pos - (1, 2) - >>> event[1].pos - (2,) - >>> tx.events[2] == event[1] - True - -.. py:classmethod:: _EventItem.items - - Returns a set-like object providing a view on the items in the first event within this object. - -.. py:classmethod:: _EventItem.keys - - Returns a set-like object providing a view on the keys in the first event within this object. - -.. py:classmethod:: _EventItem.values - - Returns an object providing a view on the values in the first event within this object. - -.. _api-types-singleton: - -_Singleton ----------- - -.. py:class:: brownie.types.types._Singleton - -Internal metaclass used to create `singleton `__ objects. Instantiating a class derived from this metaclass will always return the same instance, regardless of how the child class was imported. diff --git a/docs/api.rst b/docs/api.rst index c63902693..be7b85d54 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -17,4 +17,3 @@ The following classes and methods are available when writing brownie scripts or brownie.network brownie.project brownie.test - brownie.types diff --git a/docs/compile.rst b/docs/compile.rst index 7e5002c35..4dc215c84 100644 --- a/docs/compile.rst +++ b/docs/compile.rst @@ -127,4 +127,4 @@ All build files include a ``coverageMap`` which is used when evaluating test cov * Each ``statement`` index exists on a single program counter step. The statement is considered to have executed when the corresponding opcode executes within a transaction. * Each ``branch`` index is found on two program counters, one of which is always a ``JUMPI`` instruction. A transaction must run both opcodes before the branch is considered to have executed. Whether it evaluates true or false depends on if the jump occurs. -See :ref:`coverage` for more information on test coverage evaluation. +See :ref:`test-coverage` for more information on test coverage evaluation. diff --git a/docs/conf.py b/docs/conf.py index dac93eae9..959dc0a51 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,13 +25,13 @@ def setup(sphinx): project = 'Brownie' -copyright = '2019, HyperLink Technology' +copyright = '2019' author = 'Ben Hauser' # The short X.Y version version = '' # The full version, including alpha/beta/rc tags -release = 'v1.0.0b9' +release = 'v1.0.0b10' # -- General configuration --------------------------------------------------- diff --git a/docs/config.rst b/docs/config.rst index 60f66bb97..616d3de7b 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -47,19 +47,9 @@ The following settings are available: Properties that affect only affect Brownie's configuration when running tests. See Test :ref:`test_settings` for detailed information on the effects and implications of these settings. * ``gas_limit``: Replaces the default network gas limit. - - * ``broadcast_reverting_tx``: Replaces the default network setting for broadcasting reverting transactions. - * ``default_contract_owner``: If ``false``, deployed contracts will not remember the account that they were created by and you will have to supply a ``from`` kwarg for every contract transaction. - -.. py:attribute:: logging - - Default logging levels for each brownie mode. - - * ``tx``: Transaction information - * ``exc``: Exception information - - Valid values range from 0 (nothing) to 2 (detailed). When given as a 2 item list, it corresponds to normal/verbose. When given as a single value, adding the ``--verbose`` tag will do nothing. + * ``broadcast_reverting_tx``: Replaces the default network setting for broadcasting reverting transactions. + * ``revert_traceback``: if ``true``, unhandled ``VirtualMachineError`` exceptions will include a full traceback for the reverted transaction. .. py:attribute:: colors diff --git a/docs/coverage.rst b/docs/coverage.rst deleted file mode 100644 index e7563d8fa..000000000 --- a/docs/coverage.rst +++ /dev/null @@ -1,87 +0,0 @@ -.. _coverage: - -====================== -Checking Test Coverage -====================== - -Test coverage is calculated by generating a map of opcodes associated with each statement and branch of the source code, and then analyzing the stack trace of each transaction to see which opcodes were executed. - -During analysis, all contract calls are executed as transactions. This gives a more accurate coverage picture by allowing analysis of methods that are typically non-state changing. Whenever one of these calls-as-transactions results in a state change, the blockchain will be reverted to ensure that the outcome of the test is not effected. For tests that involve many calls this can result in significantly slower execution time. You can prevent this behaviour by adding ``always_transact=False`` as a keyword argument for a test. - -To check your unit test coverage, type: - -:: - - $ brownie test --coverage - -This will run all the test scripts in the ``tests/`` folder and give an estimate of test coverage: - -:: - - $ brownie test --coverage - Brownie v1.0.0 - Python development framework for Ethereum - - Using solc version v0.5.7 - Running 4 tests across 2 modules. - - Running transfer.py - 1 test (1/2) - ✓ 0 - setup (0.1882s) - ✓ 1 - Transfer tokens (0.1615s) - ✓ 2 - Evaluating test coverage (0.0009s) - - Running approve_transferFrom.py - 3 tests (2/2) - ✓ 0 - setup (0.1263s) - ✓ 1 - Set approval (0.2016s) - ✓ 2 - Transfer tokens with transferFrom (0.1375s) - ✓ 3 - transerFrom should revert (0.0486s) - ✓ 4 - Evaluating test coverage (0.0026s) - - SUCCESS: All tests passed. - - Coverage analysis: - - contract: Token - 82.3% - SafeMath.add - 66.7% - SafeMath.sub - 100.0% - Token. - 0.0% - Token.allowance - 100.0% - Token.approve - 100.0% - Token.balanceOf - 100.0% - Token.decimals - 0.0% - Token.name - 100.0% - Token.symbol - 0.0% - Token.totalSupply - 100.0% - Token.transfer - 85.7% - Token.transferFrom - 100.0% - - Coverage report saved at reports/coverage-010170.json - -Brownie will output a % score for each contract method, that you can use to quickly gauge your overall coverage level. A coverage report is also saved in the project's ``reports`` folder. - -.. _coverage-gui: - -Brownie GUI -=========== - -For an in-depth look at your test coverage, type: - -:: - - $ brownie gui - -Or from the console: - -.. code-block:: python - - >>> Gui() - -This will open the Brownie GUI. In the upper right drop boxes, select a contract to view and then choose the generated coverage report JSON. Relevant code will be highlighted in different colors: - -* Green - code was executed during the tests -* Yellow - code was executed, but only evaluated truthfully -* Orange - code was executed, but only evaluated falsely -* Red - code was not executed - -.. image:: opview.png - -Analysis results are saved in the ``build/coverage`` folder. diff --git a/docs/debug.rst b/docs/debug.rst index 093e25a8c..f37ccdf42 100644 --- a/docs/debug.rst +++ b/docs/debug.rst @@ -4,15 +4,144 @@ Debugging Tools =============== +When using the console, transactions that revert still return a :ref:`api-network-tx` object. This object provides access to various attributes and methods that help you determine why it reverted. + .. note:: Debugging functionality relies on the `debug_traceTransaction `__ RPC method. If you are using Infura this endpoint is unavailable. Attempts to access this functionality will raise an ``RPCRequestError``. -When a transaction reverts in the console you are still returned a ``TransactionReceipt``. From this object you can call the following attributes and methods to help determine why it reverted: +Revert Strings +============== + +The first step in determining why a transaction has failed is to look at the error string it returned (the "revert string"). This is available as ``TransactionReceipt.revert_msg``, and is also displayed in the console output when the transaction confirms. Often this alone will be enough to understand what has gone wrong. + +.. code-block:: python + + >>> tx = token.transfer(accounts[1], 11000, {'from': accounts[0]}) + + Transaction sent: 0xd31c1c8db46a5bf2d3be822778c767e1b12e0257152fcc14dcf7e4a942793cb4 + SecurityToken.transfer confirmed (Insufficient Balance) - block: 13 gas used: 226266 (2.83%) + + + >>> tx.revert_msg + 'Insufficient Balance' + +A good coding practice is to use one expression per ``require`` so your revert strings can be more precise. For example, if a transaction fails from the following require statement you cannot immediately tell whether it failed because of the balance or the allowance: + +.. code-block:: solidity + + function transferFrom(address _from, address _to, uint _amount) external returns (bool) { + require (allowed[_from][msg.sender] >= _amount && balance[_from] >= _amount); + +By seperating the ``require`` expressions, unique revert strings are possible and determining the cause becomes trivial: + +.. code-block:: solidity + + function transferFrom(address _from, address _to, uint _amount) external returns (bool) { + require (allowed[_from][msg.sender] >= _amount, "Insufficient allowance"); + require (balance[_from][] >= _amount, "Insufficient Balance"); + +Contract Source Code +==================== + +You can call ``TransactionReceipt.error()`` to display the section of the contract source that caused the revert. Note that in some situations, particiarly where an ``INVALID`` opcode is raised, the source may not be available. + +.. code-block:: python + + >>> tx.error() + Trace step 5197, program counter 9719: + File "contracts/SecurityToken.sol", line 136, in SecurityToken._checkTransfer: + require(balances[_addr[SENDER]] >= _value, "Insufficient Balance"); + +Sometimes the source that reverted is insufficient to determine what went wrong, for example if a SafeMath ``require`` failed. In this case you can call ``TransactionReceipt.traceback()`` to view a python-like traceback for the failing transaction. It shows source highlights at each jump leading up to the revert. + +.. code-block:: python + + >>> tx.traceback() + Traceback for '0xd31c1c8db46a5bf2d3be822778c767e1b12e0257152fcc14dcf7e4a942793cb4': + Trace step 169, program counter 3659: + File "contracts/SecurityToken.sol", line 156, in SecurityToken.transfer: + _transfer(msg.sender, [msg.sender, _to], _value); + Trace step 5070, program counter 5666: + File "contracts/SecurityToken.sol", lines 230-234, in SecurityToken._transfer: + _addr = _checkTransfer( + _authID, + _id, + _addr + ); + Trace step 5197, program counter 9719: + File "contracts/SecurityToken.sol", line 136, in SecurityToken._checkTransfer: + require(balances[_addr[SENDER]] >= _value, "Insufficient Balance"); + +Events +====== + +Brownie provides access to events that fired in reverted transactions. They are viewable via ``TransactionReceipt.events`` in the same way as events for successful transactions. If you cannot determine why a transaction reverted or are getting unexpected results, one approach is to add temporary logging events into your code to see the values of different variables during execution. + +See the :ref:`events` section of :ref:`interaction` for information on event data is stored. + +The Transaction Trace +===================== + +The best way to understand exactly happened in a failing transaction is to generate and examine the `transaction trace `_. This is available as a list of dictionaries at ``TransactionReceipt.trace``, with several fields added to make it easier to understand. + +Each step in the trace includes the following data: + +.. code-block:: javascript + + { + 'address': "", // address of the contract containing this opcode + 'contractName': "", // contract name + 'depth': 0, // the number of external jumps away the initially called contract (starts at 0) + 'error': "", // occurred error + 'fn': "", // function name + 'gas': 0, // remaining gas + 'gasCost': 0, // cost to execute this opcode + 'jumpDepth': 1, // number of internal jumps within the active contract (starts at 1) + 'memory': [], // execution memory + 'op': "", // opcode + 'pc': 0, // program counter + 'source': { + 'filename': "path/to/file.sol", // path to contract source + 'offset': [0, 0] // start:stop offset associated with this opcode + }, + 'stack': [], // execution stack + 'storage': {} // contract storage + } + +Call Traces +=========== + +Because the trace is often many thousands of steps long, it can be challenging to know where to begin when examining it. Brownie provides the ``TransactionReceipt.call_trace()`` method to view a complete map of every jump that occured in the transaction, along with associated trace indexes: + +.. code-block:: python -* ``TransactionReceipt.trace``: The call trace structLog as a list. -* ``TransactionReceipt.revert_msg``: The error string returned when the EVM reverted, if any. -* ``TransactionReceipt.events``: The events that were emitted before the transaction reverted. -* ``TransactionReceipt.error()``: Displays the filename, line number, and lines of code that caused the revert. -* ``TransactionReceipt.call_trace()``: Displays the sequence of contracts and functions called while executing this transaction, and the structLog list index where each call or jump occured. Any functions that terminated with a ``REVERT`` opcode are highlighted in red. + >>> tx.call_trace() + Call trace for '0xd31c1c8db46a5bf2d3be822778c767e1b12e0257152fcc14dcf7e4a942793cb4': + SecurityToken.transfer 0:5198 (0xea53cB8c11f96243CE3A29C55dd9B7D761b2c0BA) + └─SecurityToken._transfer 170:5198 + ├─IssuingEntity.transferTokens 608:4991 (0x40b49Ad1B8D6A8Df6cEdB56081D51b69e6569e06) + │ ├─IssuingEntity.checkTransfer 834:4052 + │ │ ├─IssuingEntity._getID 959:1494 + │ │ │ └─KYCRegistrar.getID 1186:1331 (0xa79269260195879dBA8CEFF2767B7F2B5F2a54D8) + │ │ ├─IssuingEntity._getID 1501:1635 + │ │ ├─IssuingEntity._getID 1642:2177 + │ │ │ └─KYCRegistrar.getID 1869:2014 (0xa79269260195879dBA8CEFF2767B7F2B5F2a54D8) + │ │ ├─IssuingEntity._getInvestors 2305:3540 + │ │ │ └─KYCRegistrar.getInvestors 2520:3483 (0xa79269260195879dBA8CEFF2767B7F2B5F2a54D8) + │ │ │ ├─KYCBase.isPermitted 2874:3003 + │ │ │ │ └─KYCRegistrar.isPermittedID 2925:2997 + │ │ │ └─KYCBase.isPermitted 3014:3143 + │ │ │ └─KYCRegistrar.isPermittedID 3065:3137 + │ │ └─IssuingEntity._checkTransfer 3603:4037 + │ ├─IssuingEntity._setRating 4098:4162 + │ ├─IssuingEntity._setRating 4204:4268 + │ ├─SafeMath32.add 4307:4330 + │ └─IssuingEntity._incrementCount 4365:4770 + │ ├─SafeMath32.add 4400:4423 + │ ├─SafeMath32.add 4481:4504 + │ ├─SafeMath32.add 4599:4622 + │ └─SafeMath32.add 4692:4715 + └─SecurityToken._checkTransfer 5071:5198 -See the :ref:`api-network-tx` section of the API documentation for more detailed information. +Each line shows the active contract and function name, the trace indexes where the function is entered and exitted, and an address if the function was entered via an external jump. Functions that terminated with ``REVERT`` or ``INVALID`` opcodes are highlighted in red. +Calling ``call_trace`` provides an initial high level overview of the transaction execution path, which helps you to examine the individual trace steps in a more targetted manner. diff --git a/docs/deploy.rst b/docs/deploy.rst index b07c133e1..606d8b9bc 100644 --- a/docs/deploy.rst +++ b/docs/deploy.rst @@ -4,9 +4,9 @@ Deploying Contracts =================== -Along with the console, you can interact with your project by writing Python scripts. Scriping is especially useful for deploying contracts and automating common processes. +Brownie lets you write scripts to interact with your project. Scripting is especially useful for deploying your contracts to the main-net, or for automating processes that you perform regularly. -Every script must begin with ``from brownie import *``. This imports the instantiated project classes into the local namespace and gives access to the full Brownie :ref:`api`. +Every script should begin with ``from brownie import *``. This imports the instantiated project classes into the local namespace and gives access to the Brownie :ref:`api` in exactly the same way as if you were using the console. To execute a script from the command line: @@ -14,7 +14,7 @@ To execute a script from the command line: $ brownie run