diff --git a/CHANGELOG b/CHANGELOG index 3d403025a..51e2b2836 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,15 +1,25 @@ +1.0.0b5 +------- + + - Use relative paths in build json files + - Revert calls-as-transactions when evaluating coverage + - Significant refactor and optimizations to coverage analysis + - changes to coverageMap format, add coverageMapTotals + - Save coverage data to reports/ subfolder + - Improvements to GUI + 1.0.0b4 ------- - Add broadcast_reverting_tx flag - Use py-solc-x 0.4.0 - Detect and attach to an already active RPC client, better verbosity on RPC exceptions - - introduce Singleton metaclass and refactor code to take advantage - - add EventDict and EventItem classes for transaction event logs + - Introduce Singleton metaclass and refactor code to take advantage + - Add EventDict and EventItem classes for transaction event logs - cli.color, add _print_as_dict _print_as_list _dir_color attributes - - add conversion methods in types.convert - - remove brownie.utils package, move modules to network and project packages - - bugfixes and minor changes + - Add conversion methods in types.convert + - Remove brownie.utils package, move modules to network and project packages + - Bugfixes and minor changes 1.0.0b3 ------- @@ -95,4 +105,4 @@ 0.9.0 ----- - - Initial release \ No newline at end of file + - Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9f4a906b..9307619a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ $ git clone https://github.com/HyperLink-Technology/brownie.git Pull requests are welcomed! Please try to adhere to the following. +- Follow PEP8 code standards (max line length 100) - include any relevant documentation updates It's a good idea to make pull requests early on. A pull request represents the diff --git a/README.md b/README.md index 4f0939827..a32a520b8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ Brownie is a python framework for deploying, testing and interacting with Ethere As Brownie relies on [py-solc-x](https://github.com/iamdefinitelyahuman/py-solc-x), you do not need solc installed locally but you must install all required [solc dependencies](https://solidity.readthedocs.io/en/latest/installing-solidity.html#binary-packages). - ## Installation You can install the latest release via pip: @@ -44,9 +43,8 @@ Brownie documentation is hosted at [Read the Docs](https://eth-brownie.readthedo Help is always appreciated! In particular, Brownie needs work in the following areas before we can comfortably take it out of beta: * Tests -* Improving the documentation -* More tests * Travis or other CI +* More tests Feel free to open an issue if you find a problem, or a pull request if you've solved an issue. diff --git a/brownie/_config.py b/brownie/_config.py index 6d50e7142..0c55b3d7f 100644 --- a/brownie/_config.py +++ b/brownie/_config.py @@ -11,8 +11,8 @@ _Singleton ) -REPLACE_IN_UPDATE = ['active_network', 'networks'] -IGNORE_IF_MISSING = ['folders', 'logging'] +REPLACE = ['active_network', 'networks'] +IGNORE = ['folders', 'logging'] def _load_default_config(): @@ -30,7 +30,7 @@ def _load_default_config(): 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: + except Exception: config['logging'] = {"tx": 1, "exc": 1} return config @@ -71,18 +71,19 @@ def modify_network_config(network=None): # merges project .json with brownie .json def _recursive_update(original, new, base): for k in new: - if type(new[k]) is dict and k in REPLACE_IN_UPDATE: + if type(new[k]) is dict and k in REPLACE: original[k] = new[k] elif type(new[k]) is dict and k in original: _recursive_update(original[k], new[k], base+[k]) else: original[k] = new[k] - for k in [i for i in original if i not in new and not set(base+[i]).intersection(IGNORE_IF_MISSING)]: + 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])) ) + # move argv flags into FalseyDict ARGV = _Singleton("Argv", (FalseyDict,), {})() for key in [i for i in sys.argv if i[:2] == "--"]: diff --git a/brownie/cli/__main__.py b/brownie/cli/__main__.py index af0e55784..7cf462030 100644 --- a/brownie/cli/__main__.py +++ b/brownie/cli/__main__.py @@ -9,7 +9,7 @@ from brownie.cli.utils import color import brownie.project as project -__version__ = "1.0.0b4" # did you change this in docs/conf.py as well? +__version__ = "1.0.0b5" # did you change this in docs/conf.py as well? __doc__ = """Usage: brownie [...] [options ] @@ -44,7 +44,6 @@ def main(): args = docopt(__doc__) sys.argv += opts - cmd_list = [i.stem for i in Path(__file__).parent.glob('[!_]*.py')] if args[''] not in cmd_list: sys.exit("Invalid command. Try 'brownie --help' for available commands.") diff --git a/brownie/cli/bake.py b/brownie/cli/bake.py index 4df7e0133..7a3ea0460 100644 --- a/brownie/cli/bake.py +++ b/brownie/cli/bake.py @@ -29,7 +29,6 @@ """ - def main(): args = docopt(__doc__) path = Path(args[''] or '.').resolve() @@ -54,4 +53,4 @@ def main(): ) print("Brownie mix '{}' has been initiated at {}".format(args[''], final_path)) - sys.exit() \ No newline at end of file + sys.exit() diff --git a/brownie/cli/compile.py b/brownie/cli/compile.py index 6d172f3b6..1a399f577 100644 --- a/brownie/cli/compile.py +++ b/brownie/cli/compile.py @@ -1,7 +1,6 @@ #!/usr/bin/python3 from docopt import docopt -from pathlib import Path import shutil import brownie.project as project @@ -18,7 +17,7 @@ def main(): - args = docopt(__doc__) + docopt(__doc__) project_path = project.check_for_project('.') build_path = project_path.joinpath('build/contracts') if ARGV['all']: diff --git a/brownie/cli/console.py b/brownie/cli/console.py index b7ba9ae1d..db7e9a1bc 100644 --- a/brownie/cli/console.py +++ b/brownie/cli/console.py @@ -7,17 +7,17 @@ import sys import threading +import brownie +import brownie.network as network +from brownie.cli.utils import color +from brownie._config import ARGV, CONFIG + if sys.platform == "win32": from pyreadline import Readline readline = Readline() else: import readline -import brownie -import brownie.network as network -from brownie.cli.utils import color -from brownie._config import ARGV, CONFIG - __doc__ = """Usage: brownie console [options] diff --git a/brownie/cli/gui.py b/brownie/cli/gui.py index b9b6425e0..ec93dcac5 100644 --- a/brownie/cli/gui.py +++ b/brownie/cli/gui.py @@ -7,7 +7,7 @@ __doc__ = """Usage: brownie gui Options: - --help -h Display this message + --help -h Display this message Opens the brownie GUI. Basic functionality is as follows: @@ -28,7 +28,7 @@ def main(): - args = docopt(__doc__) + docopt(__doc__) print("Loading Brownie GUI...") Gui().mainloop() print("GUI was terminated.") diff --git a/brownie/cli/init.py b/brownie/cli/init.py index 3e608eb7c..3fad9caae 100644 --- a/brownie/cli/init.py +++ b/brownie/cli/init.py @@ -22,6 +22,7 @@ build/ Compiled contracts and test data contracts/ Contract source code +reports/ Report files for contract analysis scripts/ Scripts for deployment and interaction tests/ Scripts for project testing brownie-config.json Project configuration file""" diff --git a/brownie/cli/run.py b/brownie/cli/run.py index 62d6f99eb..3f0b4e579 100644 --- a/brownie/cli/run.py +++ b/brownie/cli/run.py @@ -5,13 +5,11 @@ from pathlib import Path import sys - import brownie.network as network from brownie.cli.utils import color from brownie._config import ARGV, CONFIG - __doc__ = """Usage: brownie run [] [options] Arguments: @@ -27,6 +25,8 @@ Use run to execute scripts that deploy or interact with contracts on the network. """.format(CONFIG['network_defaults']['name']) +ERROR = "{0[error]}ERROR{0}: ".format(color) + def main(): args = docopt(__doc__) @@ -34,11 +34,13 @@ def main(): name = args[''].replace(".py", "") fn = args[''] or "main" if not Path(CONFIG['folders']['project']).joinpath('scripts/{}.py'.format(name)): - sys.exit("{0[error]}ERROR{0}: Cannot find {0[module]}scripts/{1}.py{0}".format(color, name)) + sys.exit(ERROR+"Cannot find {0[module]}scripts/{1}.py{0}".format(color, name)) network.connect(ARGV['network']) module = importlib.import_module("scripts."+name) if not hasattr(module, fn): - sys.exit("{0[error]}ERROR{0}: {0[module]}scripts/{1}.py{0} has no '{0[callable]}{2}{0}' function.".format(color, name, fn)) + sys.exit(ERROR+"{0[module]}scripts/{1}.py{0} has no '{0[callable]}{2}{0}' function.".format( + color, name, fn + )) print("Running '{0[module]}{1}{0}.{0[callable]}{2}{0}'...".format(color, name, fn)) try: getattr(module, fn)() @@ -46,6 +48,6 @@ def main(): except Exception as e: if CONFIG['logging']['exc'] >= 2: print("\n"+color.format_tb(sys.exc_info())) - print("\n{0[error]}ERROR{0}: Script '{0[module]}{1}{0}' failed from unhandled {2}: {3}".format( + print(ERROR+"Script '{0[module]}{1}{0}' failed from unhandled {2}: {3}".format( color, name, type(e).__name__, e )) diff --git a/brownie/cli/test.py b/brownie/cli/test.py index c0d8c3b4a..c79462f62 100644 --- a/brownie/cli/test.py +++ b/brownie/cli/test.py @@ -11,12 +11,18 @@ import time from brownie.cli.utils import color -from brownie.test.coverage import merge_coverage, analyze_coverage -from brownie.exceptions import ExpectedFailing, VirtualMachineError +from brownie.test.coverage import ( + analyze_coverage, + merge_coverage_eval, + merge_coverage_files, + generate_report +) +from brownie.exceptions import ExpectedFailing import brownie.network as network from brownie.network.history import TxHistory import brownie.network.transaction as transaction from brownie.project.build import Build, get_ast_hash +from brownie.types import FalseyDict from brownie._config import ARGV, CONFIG COVERAGE_COLORS = [ @@ -25,6 +31,11 @@ (1, "bright green") ] +WARN = "{0[error]}WARNING{0}: ".format(color) +ERROR = "{0[error]}ERROR{0}: ".format(color) + +history = TxHistory() + __doc__ = """Usage: brownie test [] [] [options] Arguments: @@ -32,241 +43,339 @@ Number or range of tests to run from file Options: - - --coverage -c Evaluate test coverage and display a report - --update -u Only run tests where changes have occurred - --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 + + --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 _run_test(module, fn_name, count, total): - fn = getattr(module, fn_name) - desc = fn.__doc__ or fn_name - sys.stdout.write(" {0} - {1} ({0}/{2})...".format(count, desc, total)) - sys.stdout.flush() - if fn.__defaults__: - args = dict(zip( - fn.__code__.co_varnames[:len(fn.__defaults__)], - fn.__defaults__ - )) - if 'skip' in args and args['skip']: - sys.stdout.write( - "\r {0[pending]}\u229d{0[dull]} {1} - ".format(color, count) + - "{1} ({0[pending]}skipped{0[dull]}){0}\n".format(color, desc) - ) - return [] - else: - args = {} - try: - stime = time.time() - if ARGV['coverage'] and 'always_transact' in args: - ARGV['always_transact'] = args['always_transact'] - fn() - if ARGV['coverage']: - ARGV['always_transact'] = True - if 'pending' in args and args['pending']: - raise ExpectedFailing("Test was expected to fail") - sys.stdout.write("\r {0[success]}\u2713{0} {1} - {2} ({3:.4f}s) \n".format( - color, count, desc, time.time()-stime - )) - sys.stdout.flush() - return [] - except Exception as e: - if type(e) != ExpectedFailing and 'pending' in args and args['pending']: - c = [color('success'), color('dull'), color()] - else: - c = [color('error'), color('dull'), color()] - sys.stdout.write("\r {0[0]}{1}{0[1]} {4} - {2} ({0[0]}{3}{0[1]}){0[2]}\n".format( - c, - '\u2717' if type(e) in (AssertionError, VirtualMachineError) else '\u203C', - desc, - type(e).__name__, - count - )) - sys.stdout.flush() - if type(e) != ExpectedFailing and 'pending' in args and args['pending']: - return [] - filename = str(Path(module.__file__).relative_to(CONFIG['folders']['project'])) - fn_name = filename[:-2]+fn_name - return [(fn_name, color.format_tb(sys.exc_info(), filename), type(e))] - - -def run_test(filename, network, idx): - network.rpc.reset() - if type(CONFIG['test']['gas_limit']) is int: - network.gas_limit(CONFIG['test']['gas_limit']) - - module = importlib.import_module(filename.replace(os.sep, '.')) - test_names = [ - i for i in dir(module) if i not in dir(sys.modules['brownie']) and - i[0] != "_" and callable(getattr(module, i)) - ] - code = Path(CONFIG['folders']['project']).joinpath("{}.py".format(filename)).open().read() - test_names = re.findall('(?<=\ndef)[\s]{1,}[^(]*(?=\([^)]*\)[\s]*:)', code) - test_names = [i.strip() for i in test_names if i.strip()[0] != "_"] - duplicates = set([i for i in test_names if test_names.count(i) > 1]) - if duplicates: - raise ValueError( - "tests/{}.py contains multiple tests of the same name: {}".format( - filename, ", ".join(duplicates) - ) - ) - traceback_info = [] - test_history = set() - if not test_names: - print("\n{0[error]}WARNING{0}: No test functions in {0[module]}{1}.py{0}".format(color, filename)) - return [], [] - - print("\nRunning {0[module]}{1}.py{0} - {2} test{3}".format( - color, - filename, - len(test_names)-1, - "s" if len(test_names) != 2 else "" - )) - if 'setup' in test_names: - test_names.remove('setup') - traceback_info += _run_test(module, 'setup', 0, len(test_names)) - if traceback_info: - return TxHistory().copy(), traceback_info - network.rpc.snapshot() - for c, t in enumerate(test_names[idx], start=idx.start + 1): - network.rpc.revert() - traceback_info += _run_test(module, t, c, len(test_names)) - if traceback_info and traceback_info[-1][2] == ReadTimeout: - print(" {0[error]}WARNING{0}: RPC crashed, terminating test".format(color)) - network.rpc.kill(False) - network.rpc.launch(CONFIG['active_network']['test-rpc']) - break - test_history.update(TxHistory().copy()) - return test_history, traceback_info - - -def get_test_files(path): - if not path: - path = "" - if path[:6] != "tests/": - path = "tests/"+path - path = Path(CONFIG['folders']['project']).joinpath(path) - if not path.is_dir(): - if not path.suffix: - path = Path(str(path)+".py") - if not path.exists(): - sys.exit("{0[error]}ERROR{0}: Cannot find {0[module]}tests/{1}{0}".format(color, path.name)) - result = [path] - else: - result = [i for i in path.glob('**/*.py') if i.name[0]!="_" and "/_" not in str(i)] - return [str(i.relative_to(CONFIG['folders']['project']))[:-3] for i in result] - - def main(): args = docopt(__doc__) ARGV._update_from_args(args) if ARGV['coverage']: ARGV['always_transact'] = True - traceback_info = [] - test_files = get_test_files(args['']) + history._revert_lock = True + + test_paths = get_test_paths(args['']) + coverage_files, test_data = get_test_data(test_paths) - if len(test_files) == 1 and args['']: + if len(test_paths) == 1 and args['']: try: idx = args[''] if ':' in idx: idx = slice(*[int(i)-1 for i in idx.split(':')]) else: idx = slice(int(idx)-1, int(idx)) - except: - sys.exit("{0[error]}ERROR{0}: Invalid range. Must be an integer or slice (eg. 1:4)".format(color)) + if 'setup' in test_data[0][4]: + test_data[0][4].remove('setup') + test_data[0][4] = ['setup'] + test_data[0][4][idx] + else: + test_data[0][4] = ['setup'] + test_data[0][4][idx] + except Exception: + sys.exit(ERROR+"Invalid range. Must be an integer or slice (eg. 1:4)") elif args['']: - sys.exit("{0[error]}ERROR:{0} Cannot specify a range when running multiple tests files.".format(color)) + sys.exit(ERROR+"Cannot specify a range when running multiple tests files.") + + if not test_data: + print("No tests to run.") else: - idx = slice(0, None) + run_test_modules(test_data, not args['']) + if ARGV['coverage']: + display_report(coverage_files, not args['']) + if ARGV['gas']: + display_gas_profile() - network.connect(ARGV['network']) + +def get_test_paths(path): + if not path: + path = "" + if path[:6] != "tests/": + path = "tests/" + path + path = Path(CONFIG['folders']['project']).joinpath(path) + if path.is_dir(): + return [i for i in path.glob('**/*.py') if i.name[0] != "_" and "/_" not in str(i)] + if not path.suffix: + path = Path(str(path)+".py") + if not path.exists(): + sys.exit(ERROR+"Cannot find {0[module]}tests/{1}{0}".format(color, path.name)) + return [path] + + +def get_test_data(test_paths): coverage_files = [] + test_data = [] + project = Path(CONFIG['folders']['project']) + build_path = project.joinpath('build/coverage') + for path in test_paths: + path = path.relative_to(project) + coverage_json = build_path.joinpath(path.parent.relative_to('tests')) + coverage_json = coverage_json.joinpath(path.stem+".json") + coverage_files.append(coverage_json) + if coverage_json.exists(): + coverage_eval = json.load(coverage_json.open())['coverage'] + if ARGV['update'] and (coverage_eval or not ARGV['coverage']): + continue + else: + coverage_eval = {} + for p in list(coverage_json.parents)[::-1]: + p.mkdir(exist_ok=True) + module_name = str(path)[:-3].replace(os.sep, '.') + module = importlib.import_module(module_name) + test_names = re.findall(r'\ndef[\s ]{1,}([^_]\w*)[\s ]*\([^)]*\)', path.open().read()) + if not test_names: + print("\n{0}No test functions in {1[module]}{2}.py{1}".format(WARN, color, path)) + continue + duplicates = set([i for i in test_names if test_names.count(i) > 1]) + if duplicates: + raise ValueError("{} contains multiple test methods of the same name: {}".format( + path, + ", ".join(duplicates) + )) + if 'setup' in test_names: + fn, args = _get_fn(module, 'setup') + if args['skip'] is True or (args['skip'] == "coverage" and ARGV['coverage']): + continue + test_data.append((module, coverage_json, coverage_eval, test_names)) + return coverage_files, test_data - try: - for filename in test_files: - coverage_json = Path(CONFIG['folders']['project']) - coverage_json = coverage_json.joinpath("build/coverage"+filename[5:]+".json") - coverage_files.append(coverage_json) - if coverage_json.exists(): - coverage_eval = json.load(coverage_json.open())['coverage'] - if ARGV['update'] and (coverage_eval or not ARGV['coverage']): - continue - else: - coverage_eval = {} - for p in list(coverage_json.parents)[::-1]: - if not p.exists(): - p.mkdir() - test_history, tb = run_test(filename, network, idx) +def run_test_modules(test_data, save): + TestPrinter.grand_total = len(test_data) + count = sum([len([x for x in i[3] if x != "setup"]) for i in test_data]) + print("Running {} tests across {} modules.".format(count, len(test_data))) + network.connect(ARGV['network']) + for key in ('broadcast_reverting_tx', 'gas_limit'): + CONFIG['active_network'][key] = CONFIG['test'][key] + if not CONFIG['active_network']['broadcast_reverting_tx']: + print("{0[error]}WARNING{0}: Reverting transactions will NOT be broadcasted.".format(color)) + traceback_info = [] + start_time = time.time() + try: + for (module, coverage_json, coverage_eval, test_names) in test_data: + tb, cov = run_test(module, network, test_names) if tb: traceback_info += tb if coverage_json.exists(): coverage_json.unlink() continue - if args['--coverage']: - stime = time.time() - sys.stdout.write(" - Evaluating test coverage...") - sys.stdout.flush() - coverage_eval = analyze_coverage(test_history) - sys.stdout.write( - "\r {0[success]}\u2713{0} - ".format(color) + - "Evaluating test coverage ({:.4f}s)\n".format(time.time()-stime) - ) - sys.stdout.flush() - build_folder = Path(CONFIG['folders']['project']).joinpath('build/contracts') - build_files = set(build_folder.joinpath(i+'.json') for i in coverage_eval) + if not save: + continue + if ARGV['coverage']: + coverage_eval = cov + + build_files = set(Path('build/contracts/{}.json'.format(i)) for i in coverage_eval) coverage_eval = { 'coverage': coverage_eval, 'sha1': dict((str(i), Build()[i.stem]['bytecodeSha1']) for i in build_files) } - if args['']: - continue - - test_path = Path(CONFIG['folders']['project']).joinpath(filename+".py") - coverage_eval['sha1'][str(test_path)] = get_ast_hash(test_path) - + coverage_eval['sha1'][module.__file__] = get_ast_hash(module.__file__) json.dump( coverage_eval, coverage_json.open('w'), sort_keys=True, - indent=4, + indent=2, default=sorted ) except KeyboardInterrupt: print("\n\nTest execution has been terminated by KeyboardInterrupt.") sys.exit() finally: + print("\nTotal runtime: {:.4f}s".format(time.time() - start_time)) if traceback_info: - print("\n{0[error]}WARNING{0}: {1} test{2} failed.{0}".format( - color, len(traceback_info), "s" if len(traceback_info) > 1 else "" + print("{0}{1} test{2} failed.".format( + WARN, + len(traceback_info), + "s" if len(traceback_info) > 1 else "" )) for err in traceback_info: - print("\nException info for {0[0]}:\n{0[1]}".format(err)) + print("\nTraceback for {0[0]}:\n{0[1]}".format(err)) sys.exit() + print("{0[success]}SUCCESS{0}: All tests passed.".format(color)) + + +def run_test(module, network, test_names): + network.rpc.reset() + + if 'setup' in test_names: + test_names.remove('setup') + fn, default_args = _get_fn(module, 'setup') - print("\n{0[success]}SUCCESS{0}: All tests passed.".format(color)) - - if args['--coverage']: - print("\nCoverage analysis:\n") - coverage_eval = merge_coverage(coverage_files) - - for contract in coverage_eval: - print(" contract: {0[contract]}{1}{0}".format(color, contract)) - for fn_name, pct in [(x,v[x]['pct']) for v in coverage_eval[contract].values() for x in v]: - c = next(i[1] for i in COVERAGE_COLORS if pct<=i[0]) - print(" {0[contract_method]}{1}{0} - {2}{3:.1%}{0}".format( - color, fn_name, color(c), pct - )) - print() - - if args['--gas']: - print('\nGas Profile:') - for i in sorted(transaction.gas_profile): - print("{0} - avg: {1[avg]:.0f} low: {1[low]} high: {1[high]}".format(i, transaction.gas_profile[i])) + if default_args['skip'] is True or ( + default_args['skip'] == "coverage" and ARGV['coverage'] + ): + return [], {} + p = TestPrinter(module.__file__, 0, len(test_names)) + tb, coverage_eval = run_test_method(fn, default_args, {}, p) + if tb: + return tb, {} + else: + p = TestPrinter(module.__file__, 1, len(test_names)) + default_args = FalseyDict() + coverage_eval = {} + network.rpc.snapshot() + traceback_info = [] + for t in test_names: + network.rpc.revert() + fn, fn_args = _get_fn(module, t) + args = default_args.copy() + args.update(fn_args) + tb, coverage_eval = run_test_method(fn, args, coverage_eval, p) + traceback_info += tb + if tb and tb[0][2] == ReadTimeout: + print(WARN+"RPC crashed, terminating test") + network.rpc.kill(False) + network.rpc.launch(CONFIG['active_network']['test-rpc']) + break + if traceback_info and ARGV['coverage']: + coverage_eval = {} + p.finish() + return traceback_info, coverage_eval + + +def run_test_method(fn, args, coverage_eval, p): + desc = fn.__doc__ or fn.__name__ + if args['skip'] is True or (args['skip'] == "coverage" and ARGV['coverage']): + p.skip(desc) + return [], coverage_eval + p.start(desc) + try: + if ARGV['coverage'] and 'always_transact' in args: + ARGV['always_transact'] = args['always_transact'] + fn() + if ARGV['coverage']: + ARGV['always_transact'] = True + coverage_eval = merge_coverage_eval( + coverage_eval, + analyze_coverage(history.copy()) + ) + history.clear() + if args['pending']: + raise ExpectedFailing("Test was expected to fail") + p.stop() + return [], coverage_eval + except Exception as e: + p.stop(e, args['pending']) + if type(e) != ExpectedFailing and args['pending']: + return [], coverage_eval + path = Path(sys.modules[fn.__module__].__file__).relative_to(CONFIG['folders']['project']) + path = "{0[module]}{1}.{0[callable]}{2}{0}".format(color, str(path)[:-3], fn.__name__) + tb = color.format_tb(sys.exc_info(), sys.modules[fn.__module__].__file__) + return [(path, tb, type(e))], coverage_eval + + +def display_report(coverage_files, save): + coverage_eval = merge_coverage_files(coverage_files) + report = generate_report(coverage_eval) + print("\nCoverage analysis:") + for name in sorted(coverage_eval): + pct = coverage_eval[name].pop('pct') + c = color(next(i[1] for i in COVERAGE_COLORS if pct <= i[0])) + print("\n contract: {0[contract]}{1}{0} - {2}{3:.1%}{0}".format(color, name, c, pct)) + for fn_name, pct in [(x, v[x]['pct']) for v in coverage_eval[name].values() for x in v]: + print(" {0[contract_method]}{1}{0} - {2}{3:.1%}{0}".format( + color, + fn_name, + color(next(i[1] for i in COVERAGE_COLORS if pct <= i[0])), + pct + )) + if not save: + return + filename = "coverage-"+time.strftime('%d%m%y')+"{}.json" + path = Path(CONFIG['folders']['project']).joinpath('reports') + count = len(list(path.glob(filename.format('*')))) + path = path.joinpath(filename.format("-"+str(count) if count else "")) + json.dump(report, path.open('w'), sort_keys=True, indent=2, default=sorted) + print("\nCoverage report saved at {}".format(path.relative_to(CONFIG['folders']['project']))) + + +def display_gas_profile(): + print('\nGas Profile:') + gas = transaction.gas_profile + for i in sorted(gas): + print("{0} - avg: {1[avg]:.0f} low: {1[low]} high: {1[high]}".format(i, gas[i])) + + +def _get_fn(module, name): + fn = getattr(module, name) + if not fn.__defaults__: + return fn, FalseyDict() + return fn, FalseyDict(zip( + fn.__code__.co_varnames[:len(fn.__defaults__)], + fn.__defaults__ + )) + + +class TestPrinter: + + grand_count = 1 + grand_total = 0 + + def __init__(self, path, count, total): + self.path = path + self.count = count + self.total = total + self.total_time = time.time() + print("\nRunning {0[module]}{1}{0} - {2} test{3} ({4}/{5})".format( + color, + path, + total, + "s" if total != 1 else "", + self.grand_count, + self.grand_total + )) + + def skip(self, description): + self._print( + "{0} ({1[pending]}skipped{1[dull]})\n".format(description, color), + "\u229d", + "pending", + "dull" + ) + self.count += 1 + + def start(self, description): + self.desc = description + self._print("{} ({}/{})...".format(description, self.count, self.total)) + self.time = time.time() + + def stop(self, err=None, expect=False): + if not err: + self._print("{} ({:.4f}s) \n".format(self.desc, time.time() - self.time), "\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 = "{} ({}{}{})\n".format( + self.desc, + color(color_str), + err, + color('dull') + ) + self._print(msg, symbol, color_str, "dull") + self.count += 1 + + def finish(self): + print("Completed {0[module]}{1}{0} ({2:.4f}s)".format( + color, + self.path, + time.time() - self.total_time + )) + TestPrinter.grand_count += 1 + + def _print(self, msg, symbol=" ", symbol_color="success", main_color=None): + sys.stdout.write("\r {}{}{} {} - {}{}".format( + color(symbol_color), + symbol, + color(main_color), + self.count, + msg, + color + )) + sys.stdout.flush() diff --git a/brownie/cli/utils/__init__.py b/brownie/cli/utils/__init__.py index b681830c1..29ec2c652 100644 --- a/brownie/cli/utils/__init__.py +++ b/brownie/cli/utils/__init__.py @@ -2,4 +2,4 @@ from .color import Color -color = Color() \ No newline at end of file +color = Color() diff --git a/brownie/cli/utils/color.py b/brownie/cli/utils/color.py index 4211dfd61..67e9125c3 100755 --- a/brownie/cli/utils/color.py +++ b/brownie/cli/utils/color.py @@ -8,8 +8,8 @@ BASE = "\x1b[0;" MODIFIERS = { - 'bright':"1;", - 'dark':"2;" + 'bright': "1;", + 'dark': "2;" } COLORS = { @@ -31,7 +31,7 @@ class Color: - def __call__(self, color = None): + def __call__(self, color=None): if sys.platform == "win32": return "" if color in CONFIG['colors']: @@ -55,11 +55,11 @@ def __getitem__(self, color): return self(color) # format dicts for console printing - def pretty_dict(self, value, indent = 0, start=True): + def pretty_dict(self, value, indent=0, start=True): if start: sys.stdout.write(' '*indent+'{}{{'.format(self['dull'])) - indent+=4 - for c,k in enumerate(sorted(value.keys(), key= lambda k: str(k))): + indent += 4 + for c, k in enumerate(sorted(value.keys(), key=lambda k: str(k))): if c: sys.stdout.write(',') sys.stdout.write('\n'+' '*indent) @@ -69,38 +69,38 @@ def pretty_dict(self, value, indent = 0, start=True): sys.stdout.write("{0[key]}{1}{0[dull]}: ".format(self, k)) if _check_dict(value[k]): sys.stdout.write('{') - self.pretty_dict(value[k], indent,False) + self.pretty_dict(value[k], indent, False) continue if _check_list(value[k]): sys.stdout.write(str(value[k])[0]) self.pretty_list(value[k], indent, False) continue self._write(value[k]) - indent-=4 + indent -= 4 sys.stdout.write('\n'+' '*indent+'}') if start: sys.stdout.write('\n{}'.format(self)) sys.stdout.flush() # format lists for console printing - def pretty_list(self, value, indent = 0, start=True): - brackets = str(value)[0],str(value)[-1] + def pretty_list(self, value, indent=0, start=True): + brackets = str(value)[0], str(value)[-1] if start: - sys.stdout.write(' '*indent+'{}{}'.format(self['dull'],brackets[0])) + sys.stdout.write(' '*indent+'{}{}'.format(self['dull'], brackets[0])) if value and len(value) == len([i for i in value if _check_dict(i)]): # list of dicts sys.stdout.write('\n'+' '*(indent+4)+'{') - for c,i in enumerate(value): + for c, i in enumerate(value): if c: sys.stdout.write(',') self.pretty_dict(i, indent+4, False) - sys.stdout.write('\n'+ ' '*indent+brackets[1]) + sys.stdout.write('\n'+' '*indent+brackets[1]) elif ( - value and len(value)==len([i for i in value if type(i) is str]) and + value and len(value) == len([i for i in value if type(i) is str]) and set(len(i) for i in value) == {64} ): # list of bytes32 hexstrings (stack trace) - for c,i in enumerate(value): + for c, i in enumerate(value): if c: sys.stdout.write(',') sys.stdout.write('\n'+' '*(indent+4)) @@ -123,7 +123,7 @@ def _write(self, value): else: sys.stdout.write('{0[value]}{1}{0[dull]}'.format(self, value)) - def format_tb(self, exc, filename = None, start = None, stop = None): + def format_tb(self, exc, filename=None, start=None, stop=None): tb = [i.replace("./", "") for i in traceback.format_tb(exc[2])] if filename and not ARGV['tb']: try: @@ -134,6 +134,7 @@ def format_tb(self, exc, filename = None, start = None, stop = None): pass for i in range(len(tb)): info, code = tb[i].split('\n')[:2] + info = info.replace(CONFIG['folders']['project'], ".") info = [x.strip(',') for x in info.strip().split(' ')] if 'site-packages/' in info[1]: info[1] = '"'+info[1].split('site-packages/')[1] @@ -147,4 +148,4 @@ def _check_dict(value): def _check_list(value): - return type(value) in (list, tuple) or hasattr(value, "_print_as_list") \ No newline at end of file + return type(value) in (list, tuple) or hasattr(value, "_print_as_list") diff --git a/brownie/data/config.json b/brownie/data/config.json index 9fe343c91..24e24528f 100644 --- a/brownie/data/config.json +++ b/brownie/data/config.json @@ -17,7 +17,8 @@ }, "test":{ "gas_limit": 6721975, - "default_contract_owner": false + "default_contract_owner": false, + "broadcast_reverting_tx": true }, "solc":{ "optimize": true, diff --git a/brownie/exceptions.py b/brownie/exceptions.py index 1f4c0e576..0ec455cf5 100644 --- a/brownie/exceptions.py +++ b/brownie/exceptions.py @@ -26,6 +26,7 @@ def __init__(self, msg, cmd, proc, uri): ) ) + class RPCProcessError(_RPCBaseException): def __init__(self, cmd, proc, uri): @@ -38,6 +39,9 @@ def __init__(self, cmd, proc, uri): super().__init__("Able to launch RPC client, but unable to connect.", cmd, proc, uri) +class RPCRequestError(Exception): + pass + class VirtualMachineError(Exception): @@ -63,4 +67,3 @@ def __init__(self, exc): super().__init__(exc['message']+"\n"+exc['source']) else: super().__init__(exc['message']) - diff --git a/brownie/gui/__init__.py b/brownie/gui/__init__.py index 956b1724f..0e401d53f 100755 --- a/brownie/gui/__init__.py +++ b/brownie/gui/__init__.py @@ -1,3 +1,3 @@ #!/usr/bin/python3 -from .root import Root as Gui \ No newline at end of file +from .root import Root as Gui # noqa: F401 diff --git a/brownie/gui/buttons.py b/brownie/gui/buttons.py new file mode 100755 index 000000000..642736d7a --- /dev/null +++ b/brownie/gui/buttons.py @@ -0,0 +1,109 @@ +#!/usr/bin/python3 + +from pathlib import Path +import tkinter as tk + +from .styles import BUTTON_STYLE + + +class _Toggle(tk.Button): + + def __init__(self, parent, text, keybind=None): + self.active = False + super().__init__(parent, text=text, command=self.toggle) + self.root = self._root() + self.configure(**BUTTON_STYLE) + if keybind: + self.root.bind(keybind, self.toggle) + + def toggle(self, event=None): + if self.active: + self.toggle_off() + self.configure(relief="raised", background="#272727") + else: + if not self.toggle_on(): + return + self.configure(relief="sunken", background="#383838") + self.active = not self.active + + def toggle_on(self): + pass + + def toggle_off(self): + pass + + +class ScopingToggle(_Toggle): + + def __init__(self, parent): + super().__init__(parent, "Scope", "s") + self.oplist = self.root.main.oplist + + def toggle_on(self): + try: + op = self.oplist.selection()[0] + except IndexError: + return False + if self.oplist.item(op, 'tags')[0] == "NoSource": + return False + pc = self.root.pcMap[op] + for key, value in sorted(self.root.pcMap.items(), key=lambda k: int(k[0])): + if ( + not value['contract'] or value['contract'] != pc['contract'] or + value['start'] < pc['start'] or value['stop'] > pc['stop'] + ): + self.oplist.detach(key) + else: + self.oplist.move(key, '', key) + self.oplist.see(op) + self.root.main.note.apply_scope(pc['start'], pc['stop']) + return True + + def toggle_off(self): + self.root.main.note.clear_scope() + for i in sorted(self.root.pcMap, key=lambda k: int(k)): + self.oplist.move(i, '', i) + if self.oplist.selection(): + self.oplist.see(self.oplist.selection()[0]) + + +class ConsoleToggle(_Toggle): + + def __init__(self, parent): + super().__init__(parent, "Console", "c") + self.console = self.root.main.console + + def toggle_on(self): + self.console.config(height=10) + return True + + def toggle_off(self): + self.console.config(height=1) + + +class HighlightsToggle(_Toggle): + + def __init__(self, parent): + super().__init__(parent, "Highlights", "h") + self.note = self.root.main.note + + def toggle_on(self): + if not self.root.active_report: + return False + contract = self.root.get_active() + if contract not in self.root.active_report['highlights']: + return False + report = self.root.active_report['highlights'][contract] + for path, item in [(k, x) for k, v in report.items() for x in v]: + label = Path(path).name + self.note.mark(label, item[2], item[0], item[1]) + return True + + def toggle_off(self): + self.note.unmark_all('green', 'red', 'yellow', 'orange') + + def reset(self): + self.toggle_off() + self.configure(relief="raised", background="#272727") + self.active = False + self.toggle() diff --git a/brownie/gui/listview.py b/brownie/gui/listview.py index 311ba0ecb..f04ea25e2 100755 --- a/brownie/gui/listview.py +++ b/brownie/gui/listview.py @@ -7,8 +7,7 @@ class ListView(ttk.Treeview): - def __init__(self, root, parent, columns, **kwargs): - self._parent = root + def __init__(self, parent, columns, **kwargs): self._last = "" self._seek_buffer = "" self._seek_last = 0 @@ -26,14 +25,13 @@ def __init__(self, root, parent, columns, **kwargs): for tag, width in columns[1:]: self.heading(tag, text=tag) self.column(tag, width=width) - scroll=ttk.Scrollbar(self._frame) + scroll = ttk.Scrollbar(self._frame) scroll.pack(side="right", fill="y") self.configure(yscrollcommand=scroll.set) scroll.configure(command=self.yview) - self.tag_configure("NoSource", background="#272727") + self.tag_configure("NoSource", background="#161616") self.bind("<>", self._select_bind) - root.bind("a", self._show_all) - root.bind("s", self._show_scope) + root = self.root = self._root() root.bind("j", self._highlight_jumps) root.bind("r", self._highlight_revert) self.bind("<3>", self._highlight_opcode) @@ -66,14 +64,27 @@ def selection_set(self, id_): self.focus_set() self.focus(id_) + def set_opcodes(self, pcMap): + self.delete_all() + for pc, op in [(i, pcMap[i]) for i in sorted(pcMap)]: + if not op['contract'] or ( + op['contract'] == pcMap[0]['contract'] and + op['start'] == pcMap[0]['start'] and + op['stop'] == pcMap[0]['stop'] + ): + tag = "NoSource" + else: + tag = "{0[start]}:{0[stop]}:{0[contract]}".format(op) + self.insert([str(pc), op['op']], [tag, op['op']]) + def _select_bind(self, event): self.tag_configure(self._last, background="") try: pc = self.selection()[0] except IndexError: return - pcMap = self._parent.pcMap - note = self._parent.note + pcMap = self.root.pcMap + note = self.root.main.note tag = self.item(pc, 'tags')[0] if tag == "NoSource": note.active_frame().clear_highlight() @@ -83,7 +94,7 @@ def _select_bind(self, event): if not pcMap[pc]['contract']: note.active_frame().clear_highlight() return - note.set_active(pcMap[pc]['contract'].split('/')[-1]) + note.set_active(pcMap[pc]['contract']) note.active_frame().highlight(pcMap[pc]['start'], pcMap[pc]['stop']) def _seek(self, event): @@ -91,44 +102,20 @@ def _seek(self, event): self._seek_buffer = "" self._seek_last = time.time() self._seek_buffer += event.char - pc = sorted([int(i) for i in self._parent.pcMap])[::-1] - id_ = next(str(i) for i in pc if i<=int(self._seek_buffer)) + pc = sorted([int(i) for i in self.root.pcMap])[::-1] + id_ = next(str(i) for i in pc if i <= int(self._seek_buffer)) self.selection_set(id_) - def _show_all(self, event): - self._parent.note.clear_scope() - for i in sorted(self._parent.pcMap, key= lambda k: int(k)): - self.move(i, '', i) - if self.selection(): - self.see(self.selection()[0]) - - def _show_scope(self, event): - if not self.selection(): - return - pc = self._parent.pcMap[self.selection()[0]] - if not pc['contract']: - return - for key, value in sorted(self._parent.pcMap.items(), key= lambda k: int(k[0])): - if ( - not value['contract'] or value['contract']!=pc['contract'] or - value['start'] < pc['start'] or value['stop']>pc['stop'] - ): - self.detach(key) - else: - self.move(key, '', key) - self.see(self.selection()[0]) - self._parent.note.apply_scope(pc['start'], pc['stop']) - def _highlight_opcode(self, event): pc = self.identify_row(event.y) - op = self._parent.pcMap[pc]['op'] + op = self.root.pcMap[pc]['op'] if op in self._highlighted: self.tag_configure(op, foreground='') self._highlighted.remove(op) else: self.tag_configure( op, - foreground="#dddd33" if op!="REVERT" else "#dd3333" + foreground="#dddd33" if op != "REVERT" else "#dd3333" ) self._highlighted.add(op) @@ -147,4 +134,4 @@ def _highlight_revert(self, event): self._highlighted.discard("REVERT") else: self.tag_configure("REVERT", foreground="#dd3333") - self._highlighted.add("REVERT") \ No newline at end of file + self._highlighted.add("REVERT") diff --git a/brownie/gui/root.py b/brownie/gui/root.py index 0561a2c2e..ac176d76e 100755 --- a/brownie/gui/root.py +++ b/brownie/gui/root.py @@ -1,23 +1,31 @@ #!/usr/bin/python3 from pathlib import Path -import re import threading import tkinter as tk from tkinter import ttk +from .buttons import ( + ScopingToggle, + # ConsoleToggle, + HighlightsToggle +) from .listview import ListView +from .select import ContractSelect, ReportSelect +from .styles import ( + set_style, + # TEXT_STYLE +) from .textbook import TextBook -from .select import SelectContract -from .styles import set_style +from .tooltip import ToolTip from brownie.project.build import Build -from brownie.test.coverage import merge_coverage from brownie._config import CONFIG build = Build() + class Root(tk.Tk): _active = threading.Event() @@ -30,65 +38,32 @@ def __init__(self): raise SystemError("GUI is already active") self._active.set() - super().__init__(className="Opcode Viewer") + name = Path(CONFIG['folders']['project']).name + super().__init__(className=" Brownie GUI - "+name) self.bind("", lambda k: self.destroy()) - path = Path(CONFIG['folders']['project']) - self.coverage_folder = path.joinpath('build/coverage') - - self.note = TextBook(self) - self.note.pack(side="left") + # main widgets + self.main = MainFrame(self) + self.main.pack(side="bottom", expand=True, fill="both") - frame = ttk.Frame(self) - frame.pack(side="right", expand="true", fill="y") - - self.tree = ListView(self, frame, (("pc", 80), ("opcode", 200)), height=30) - self.tree.pack(side="bottom") + # toolbar widgets + self.toolbar = ToolbarFrame(self) + self.toolbar.pack(side="top", expand="true", fill="both") - self.combo = SelectContract(self, frame) - self.combo.pack(side="top", expand="true", fill="x") - - self._show_coverage = False - self.bind("c", self._toggle_coverage) + self.active_report = False set_style(self) - def _toggle_coverage(self, event): - active = self.combo.get() - if not active: - return - if self._show_coverage: - self.note.unmark_all('green', 'red', 'yellow', 'orange') - self._show_coverage = False - return - frame_path = self.note.active_frame()._path - coverage_files = self.coverage_folder.glob('**/*.json') - try: - coverage = merge_coverage(coverage_files)[active][frame_path] - except KeyError: - return - source = build[active]['source'] - coverage_map = build[active]['coverageMap'][frame_path] - label = frame_path.split('/')[-1] - self._show_coverage = True - for key, fn, lines in [(k,v['fn'],v['line']) for k,v in coverage_map.items()]: - if coverage[key]['pct'] in (0, 1): - self.note.mark( - label, - "green" if coverage[key]['pct'] else "red", - fn['start'], - fn['stop'] - ) - continue - for i, ln in enumerate(lines): - if i in coverage[key]['line']: - tag = "green" - elif i in coverage[key]['true']: - tag = "yellow" if _evaluate_branch(source, ln) else "orange" - elif i in coverage[key]['false']: - tag = "orange" if _evaluate_branch(source, ln) else "yellow" - else: - tag = "red" - self.note.mark(label, tag, ln['start'], ln['stop']) + def set_active(self, contract_name): + build_json = build[contract_name] + self.main.note.set_visible(build_json['allSourcePaths']) + self.main.note.set_active(build_json['sourcePath']) + self.main.oplist.set_opcodes(build_json['pcMap']) + self.pcMap = dict((str(k), v) for k, v in build_json['pcMap'].items()) + if self.toolbar.highlight.active: + self.toolbar.highlight.reset() + + def get_active(self): + return self.toolbar.combo.get() def destroy(self): super().destroy() @@ -96,33 +71,53 @@ def destroy(self): self._active.clear() -def _evaluate_branch(source, ln): - start, stop = ln['start'], ln['stop'] - try: - idx = _maxindex(source[:start]) - except: - return False - - # remove comments, strip whitespace - before = source[idx:start] - for pattern in ('\/\*[\s\S]*?\*\/', '\/\/[^\n]*'): - for i in re.findall(pattern, before): - before = before.replace(i, "") - before = before.strip("\n\t (") - - idx = source[stop:].index(';')+len(source[:stop]) - if idx <= stop: - return False - after = source[stop:idx].split() - after = next((i for i in after if i!=")"),after[0])[0] - if ( - (before[-2:] == "if" and after=="|") or - (before[:7] == "require" and after in (")","|")) - ): - return True - return False - - -def _maxindex(source): - comp = [i for i in [";", "}", "{"] if i in source] - return max([source.rindex(i) for i in comp])+1 +class MainFrame(ttk.Frame): + + def __init__(self, root): + super().__init__(root) + + self.oplist = ListView(self, (("pc", 80), ("opcode", 200))) + self.oplist.configure(height=30) + self.oplist.pack(side="right", fill="y", expand=True) + + frame = ttk.Frame(self) + frame.pack(side="left", fill="y", expand=True) + self.note = TextBook(frame) + self.note.pack(side="top", fill="both", expand=True) + self.note.configure(width=920, height=100) + + # GUI console - will be implemented later! + # self.console = tk.Text(frame, height=1) + # self.console.pack(side="bottom", fill="both") + # self.console.configure(**TEXT_STYLE) + + +class ToolbarFrame(ttk.Frame): + + def __init__(self, root): + super().__init__(root) + self.root = root + + # contract selection + self.combo = ContractSelect(self, [k for k, v in build.items() if v['bytecode']]) + self.combo.pack(side="right", anchor="e") + self.combo.configure(width=23) + ToolTip(self.combo, "Select the contract source to view") + + path = Path(CONFIG['folders']['project']).joinpath('reports') + + self.report = ReportSelect(self, list(path.glob('**/*.json'))) + self.report.pack(side="right", anchor="e", padx=10) + self.report.configure(width=23) + ToolTip(self.report, "Select a report to overlay onto source code") + + self.scope = ScopingToggle(self) + self.scope.pack(side="left") + ToolTip(self.scope, "Filter opcodes to only show those\nrelated to the highlighted source") + + # self.console = ConsoleToggle(self) + # self.console.pack(side="left") + + self.highlight = HighlightsToggle(self) + self.highlight.pack(side="left") + ToolTip(self.highlight, "Toggle report highlighting") diff --git a/brownie/gui/select.py b/brownie/gui/select.py index 79c421ea6..01eaa973f 100755 --- a/brownie/gui/select.py +++ b/brownie/gui/select.py @@ -1,47 +1,54 @@ #!/usr/bin/python3 +import json from tkinter import ttk -from brownie.project.build import Build -from brownie._config import CONFIG -build = Build() +class _Select(ttk.Combobox): -class SelectContract(ttk.Combobox): - - def __init__(self, root, parent): - self._parent = root + def __init__(self, parent, initial, values): super().__init__(parent, state='readonly', font=(None, 16)) - values = [] - for name, data in build.items(): - if data['bytecode']: - values.append(name) + self.root = self._root() self['values'] = sorted(values) - root.note.set_visible([]) + self.set(initial) self.bind("<>", self._select) - def _select(self, event): - self._parent.note.set_visible([]) - build_json = build[self.get()] + def _select(self): + value = self.get() self.selection_clear() - for contract in sorted(set( - i['contract'].split('/')[-1] - for i in build_json['pcMap'] if i['contract'] - )): - self._parent.note.show(contract) - first = build_json['pcMap'][0].copy() - self._parent.note.set_active(first['contract'].split('/')[-1]) - self._parent.tree.delete_all() - for op in build_json['pcMap']: - if ( - op['contract'] == first['contract'] and - op['start'] == first['start'] and - op['stop'] == first['stop'] - ): - op['contract'] = None - if op['contract']: - tag = "{0[start]}:{0[stop]}:{0[contract]}".format(op) - else: - tag = "NoSource" - self._parent.tree.insert([str(op['pc']), op['op']], [tag, op['op']]) - self._parent.pcMap = dict((str(i.pop('pc')), i) for i in build_json['pcMap']) + return value + + +class ContractSelect(_Select): + + def __init__(self, parent, values): + super().__init__(parent, "Select a Contract", values) + + def _select(self, event): + value = super()._select() + self.root.set_active(value) + + +class ReportSelect(_Select): + + def __init__(self, parent, report_paths): + self._reports = {} + for path in report_paths: + try: + self._reports[path.stem] = json.load(path.open()) + except Exception: + continue + super().__init__( + parent, + "Select a Report" if self._reports else "No Available Reports", + sorted(self._reports) + ) + if not self._reports: + self.config(state="disabled") + + def _select(self, event): + value = super()._select() + if self.root.active_report == self._reports[value]: + return + self.root.active_report = self._reports[value] + self.root.toolbar.highlight.reset() diff --git a/brownie/gui/styles.py b/brownie/gui/styles.py index 46a0baccd..6e591643c 100755 --- a/brownie/gui/styles.py +++ b/brownie/gui/styles.py @@ -1,17 +1,17 @@ #!/usr/bin/python3 from tkinter import ttk -import tkinter as tk TEXT_STYLE = { 'font': ("Courier", 14), 'background': "#383838", - 'foreground': "#ECECEC", + 'foreground': "#FFFFFF", 'selectforeground': "white", 'selectbackground': "#4a6984", 'inactiveselectbackground': "#4a6984", - 'borderwidth': 1, + 'borderwidth': 0, 'highlightthickness': 0, + 'state': "disabled" } TEXT_COLORS = { @@ -24,42 +24,56 @@ } +BUTTON_STYLE = { + 'borderwidth': 1, + 'background': "#272727", + 'foreground': "#ECECEC", + 'highlightthickness': 0, + 'activebackground': "#383838", + 'activeforeground': "white" +} + + def set_style(root): style = ttk.Style() style.theme_use('default') style.configure( "Treeview", - background="#383838", + background="#272727", fieldbackground="#383838", foreground="#ECECEC", font=(None, 16), rowheight=21, - borderwidth=1 + borderwidth=0, + relief="flat", ) style.configure( "Treeview.Heading", background="#161616", foreground="#ECECEC", - borderwidth=0, - font=(None, 16) + borderwidth=2, + font=(None, 16), + relief="flat", ) style.map( "Treeview.Heading", background=[("active", "#383838"), ("selected", "#383838")], foreground=[("active", "#ECECEC"), ("selected", "#ECECEC")] ) - style.configure("TNotebook", background="#272727") + style.configure("TNotebook", background="#161616", borderwidth=0) style.configure( "TNotebook.Tab", background="#272727", foreground="#a9a9a9", - font=(None, 14) + font=(None, 14), + borderwidth=1, + relief="flat", ) style.map( "TNotebook.Tab", background=[("active", "#383838"), ("selected", "#383838")], - foreground=[("active", "#ECECEC"), ("selected", "#ECECEC")] + foreground=[("active", "#ECECEC"), ("selected", "#ECECEC")], ) style.configure( "TFrame", @@ -74,14 +88,14 @@ def set_style(root): arrowsize=16, relief="flat", borderwidth=0, - arrowcolor="#a9a9a9" + arrowcolor="#a9a9a9", ) style.map( "TScrollbar", - background=[('active', "#272727")] + background=[('active', "#272727")], ) style.layout( - 'Vertical.TScrollbar', + 'Vertical.TScrollbar', [( 'Vertical.Scrollbar.trough', { @@ -100,16 +114,17 @@ def set_style(root): "TCombobox", foreground="#000000", background="#555555", - borderwitdh=0, + borderwidth=0, arrowsize=24 ) style.map( "TCombobox", background=[("active", "#666666"), ("selected", "#383838")], - fieldbackground=[("readonly","#A9A9A9")] + fieldbackground=[("readonly", "#A9A9A9")], + borderwidth=[("active", 0)], ) root.option_add("*TCombobox*Listbox*Font", (None, 18)) root.option_add("*TCombobox*Listbox.foreground", "#000000") root.option_add("*TCombobox*Listbox.background", "#A9A9A9") root.option_add("*TCombobox*Listbox.selectForeground", "#ECECEC") - root.option_add("*TCombobox*Listbox.selectBackground", "#272727") \ No newline at end of file + root.option_add("*TCombobox*Listbox.selectBackground", "#272727") diff --git a/brownie/gui/textbook.py b/brownie/gui/textbook.py index e3007bfe6..e05eefb4a 100755 --- a/brownie/gui/textbook.py +++ b/brownie/gui/textbook.py @@ -10,21 +10,24 @@ from brownie._config import CONFIG + class TextBook(ttk.Notebook): - def __init__(self, root): - super().__init__(root) - self._parent = root + def __init__(self, parent): + super().__init__(parent) + self.root = self._root() self._scope = None self.configure(padding=0) self._frames = [] - root.bind("", self.key_left) - root.bind("", self.key_right) + self.root.bind("", self.key_left) + self.root.bind("", self.key_right) base_path = Path(CONFIG['folders']['project']).joinpath('contracts') for path in base_path.glob('**/*.sol'): self.add(path) + self.set_visible([]) def add(self, path): + path = Path(path) label = path.name if label in [i._label for i in self._frames]: return @@ -37,15 +40,17 @@ def add(self, path): self._frames.append(frame) def get_frame(self, label): + label = Path(label).name return next(i for i in self._frames if i._label == label) - + def hide(self, label): frame = self.get_frame(label) if frame._visible: super().hide(frame) frame._visible = False - + def show(self, label): + label = Path(label).name frame = next(i for i in self._frames if i._label == label) if frame._visible: return @@ -53,6 +58,7 @@ def show(self, label): super().add(frame, text=" {} ".format(label)) def set_visible(self, labels): + labels = [Path(i).name for i in labels] for label in [i._label for i in self._frames]: if label in labels: self.show(label) @@ -76,7 +82,7 @@ def _key(self, visible): if not visible: return f = self.active_frame() - if visible[-1] == f: + if visible[-1] == f: self.select(visible[0]) else: self.select(visible[visible.index(f)+1]) @@ -87,7 +93,7 @@ def apply_scope(self, start, stop): self._scope = [frame, start, stop] frame.tag_add('dark', 0, start) frame.tag_add('dark', stop, 'end') - for f in [v for v in self._frames if v!=frame]: + for f in [v for v in self._frames if v != frame]: f.tag_add('dark', 0, 'end') def clear_scope(self): @@ -106,23 +112,23 @@ def unmark_all(self, *tags): for f in self._frames: for tag in tags: f.tag_remove(tag) - + def _search(self, event): frame = self.active_frame() - tree = self._parent.tree + tree = self.root.main.oplist if not frame.tag_ranges('sel'): tree.clear_selection() return start, stop = frame.tag_ranges('sel') if self._scope and ( frame != self._scope[0] or - startself._scope[2] + start < self._scope[1] or + stop > self._scope[2] ): pc = False else: pc = [ - k for k,v in self._parent.pcMap.items() if + k for k, v in self.root.pcMap.items() if v['contract'] and frame._label in v['contract'] and start >= v['start'] and stop <= v['stop'] ] @@ -130,10 +136,11 @@ def _search(self, event): frame.clear_highlight() tree.clear_selection() return + def key(k): return ( - (start - self._parent.pcMap[k]['start']) + - (self._parent.pcMap[k]['stop'] - stop) + (start - self.root.pcMap[k]['start']) + + (self.root.pcMap[k]['stop'] - stop) ) id_ = sorted(pc, key=key)[0] tree.selection_set(id_) @@ -143,45 +150,34 @@ class TextBox(tk.Frame): def __init__(self, root, text): super().__init__(root) - self._text = tk.Text( - self, - height = 35, - width = 90, - yscrollcommand = self._text_scroll - ) + self._text = tk.Text(self, width=90, yscrollcommand=self._text_scroll) self._scroll = ttk.Scrollbar(self) self._scroll.pack(side="left", fill="y") self._scroll.config(command=self._scrollbar_scroll) - self._line_no = tk.Text( - self, - height = 35, - width = 4, - yscrollcommand = self._text_scroll - ) + self._line_no = tk.Text(self, width=4, yscrollcommand=self._text_scroll) self._line_no.pack(side="left", fill="y") - self._text.pack(side="right",fill="y") + self._text.pack(side="right", fill="y") self._text.insert(1.0, text) - for k,v in TEXT_COLORS.items(): + for k, v in TEXT_COLORS.items(): self._text.tag_config(k, **v) - for pattern in ('\/\*[\s\S]*?\*\/', '\/\/[^\n]*'): - for i in re.findall(pattern, text): - idx = text.index(i) - self.tag_add('comment',idx,idx+len(i)) + pattern = r"((?:\s*\/\/[^\n]*)|(?:\/\*[\s\S]*?\*\/))" + for match in re.finditer(pattern, text): + self.tag_add('comment', match.start(), match.end()) self._line_no.insert(1.0, '\n'.join(str(i) for i in range(1, text.count('\n')+2))) self._line_no.tag_configure("justify", justify="right") self._line_no.tag_add("justify", 1.0, "end") for text in (self._line_no, self._text): - text['state'] = "disabled" text.config(**TEXT_STYLE) text.config( - tabs = tkFont.Font(font=text['font']).measure(' '), - wrap = "none" + tabs=tkFont.Font(font=text['font']).measure(' '), + wrap="none" ) + self._line_no.config(background="#272727") self._text.bind('', root._search) def __getattr__(self, attr): @@ -209,10 +205,10 @@ def tag_add(self, tag, start, end, see=False): def tag_ranges(self, tag): return [self._coord_to_offset(i.string) for i in self._text.tag_ranges(tag)] - + def tag_remove(self, tag): self._text.tag_remove(tag, 1.0, "end") - + def _offset_to_coord(self, value): text = self._text.get(1.0, "end") line = text[:value].count('\n') + 1 @@ -231,4 +227,4 @@ def _scrollbar_scroll(self, action, position, type=None): def _text_scroll(self, first, last, type=None): self._text.yview_moveto(first) self._line_no.yview_moveto(first) - self._scroll.set(first, last) \ No newline at end of file + self._scroll.set(first, last) diff --git a/brownie/gui/tooltip.py b/brownie/gui/tooltip.py new file mode 100755 index 000000000..e4a974e1c --- /dev/null +++ b/brownie/gui/tooltip.py @@ -0,0 +1,40 @@ +#!/usr/bin/python3 + +import tkinter as tk + +TOOLTIP_DELAY = 1 + + +class ToolTip(tk.Toplevel): + + def __init__(self, widget, text=None, textvariable=None): + super().__init__(widget._root()) + label = tk.Label(self, text=text, textvariable=textvariable, font=(None, 10)) + label.pack() + self.wm_overrideredirect(True) + self.withdraw() + self.kill = False + self.widget = widget + widget.bind("", self.enter) + + def enter(self, event): + self.kill = False + self.widget.bind("", self.leave) + self.widget.bind("<1>", self.leave) + self.after(int(TOOLTIP_DELAY*1000), self.show) + + def show(self): + if self.kill: + return + self.geometry("+{}+{}".format( + self.winfo_pointerx()+5, + self.winfo_pointery()+5 + )) + self.lift() + self.deiconify() + + def leave(self, event): + self.kill = True + self.widget.unbind("") + self.withdraw() + self.widget.bind("", self.enter) diff --git a/brownie/network/__init__.py b/brownie/network/__init__.py index bc679ed00..755d4f3e4 100644 --- a/brownie/network/__init__.py +++ b/brownie/network/__init__.py @@ -89,4 +89,4 @@ def gas_limit(*args): return "Invalid gas limit." return "Gas limit is set to {}".format( CONFIG['active_network']['gas_limit'] or "automatic" - ) \ No newline at end of file + ) diff --git a/brownie/network/account.py b/brownie/network/account.py index 3130cb12f..f7b7675f1 100644 --- a/brownie/network/account.py +++ b/brownie/network/account.py @@ -12,6 +12,7 @@ from brownie.cli.utils import color from brownie.exceptions import VirtualMachineError 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 @@ -28,6 +29,7 @@ def __init__(self): self._accounts = [] # prevent private keys from being stored in read history self.add.__dict__['_private'] = True + Rpc()._objects.append(self) def _reset(self): self._accounts.clear() @@ -254,7 +256,7 @@ def transfer(self, to, amount, gas_limit=None, gas_price=None, data=""): txid = self._transact({ 'from': self.address, 'nonce': self.nonce, - 'gasPrice': wei(gas_price) or self._gas_price(), + '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), 'to': str(to), 'value': wei(amount), diff --git a/brownie/network/alert.py b/brownie/network/alert.py index 949e4d15e..ebaf96bad 100644 --- a/brownie/network/alert.py +++ b/brownie/network/alert.py @@ -7,11 +7,12 @@ _instances = set() + class Alert: '''Setup notifications and callbacks based on state changes to the blockchain. The alert is immediatly active as soon as the class is insantiated.''' - + def __init__(self, fn, args=[], kwargs={}, delay=0.5, msg=None, callback=None): '''Creates a new Alert. @@ -32,7 +33,7 @@ def __init__(self, fn, args=[], kwargs={}, delay=0.5, msg=None, callback=None): args=(fn, args, kwargs, delay, msg, callback)) self._thread.start() _instances.add(self) - + def _loop(self, fn, args, kwargs, delay, msg, callback): start_value = fn(*args, **kwargs) while not self._kill: @@ -54,15 +55,18 @@ def stop(self): self._thread.join() _instances.discard(self) + def new(fn, args=[], kwargs={}, delay=0.5, msg=None, callback=None): '''Alias for creating a new Alert instance.''' return Alert(fn, args, kwargs, delay, msg, callback) + def show(): '''Returns a list of all currently active Alert instances.''' return list(_instances) + def stop_all(): '''Stops all currently active Alert instances.''' for t in _instances.copy(): - t.stop() \ No newline at end of file + t.stop() diff --git a/brownie/network/contract.py b/brownie/network/contract.py index 110272d5c..481449b49 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -8,6 +8,7 @@ from brownie.cli.utils import color from .event import get_topics 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 @@ -15,6 +16,7 @@ from brownie._config import ARGV, CONFIG _contracts = _ContractHistory() +rpc = Rpc() web3 = Web3() @@ -60,9 +62,6 @@ class ContractContainer(_ContractBase): def __init__(self, build): self.tx = None self.bytecode = build['bytecode'] - # convert pcMap to dict to speed transaction stack traces - if type(build['pcMap']) is list: - build['pcMap'] = dict((i.pop('pc'), i) for i in build['pcMap']) super().__init__(build) self.deploy = ContractConstructor(self, self._name) @@ -285,7 +284,7 @@ def call(self, *args): return format_output(result[0]) return KwargTuple(result, self.abi) - def transact(self, *args): + def transact(self, *args, _rpc_clear=True): '''Broadcasts a transaction that calls this contract method. Args: @@ -300,6 +299,8 @@ def transact(self, *args): "Contract has no owner, 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'], @@ -331,7 +332,7 @@ class ContractTx(_ContractMethod): def __init__(self, fn, abi, name, owner): if ( - ARGV['cli'] != "console" and not + ARGV['cli'] == "test" and not CONFIG['test']['default_contract_owner'] ): owner = None @@ -367,7 +368,12 @@ def __call__(self, *args): Returns: Contract method return value(s).''' if ARGV['always_transact']: - tx = self.transact(*args) + 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() return tx.return_value return self.call(*args) diff --git a/brownie/network/event.py b/brownie/network/event.py index 6745ef22f..7ba1499be 100644 --- a/brownie/network/event.py +++ b/brownie/network/event.py @@ -12,16 +12,17 @@ def _get_path(): return Path(CONFIG['folders']['brownie']).joinpath('data/topics.json') + def get_topics(abi): new_topics = _topics.copy() new_topics.update(eth_event.get_event_abi(abi)) if new_topics != _topics: _topics.update(new_topics) - json.dump( + json.dump( new_topics, _get_path().open('w'), sort_keys=True, - indent=4 + indent=2 ) return eth_event.get_topics(abi) diff --git a/brownie/network/history.py b/brownie/network/history.py index b2e625a2a..1d8533cd2 100644 --- a/brownie/network/history.py +++ b/brownie/network/history.py @@ -2,13 +2,14 @@ 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 - web3 = Web3() + class TxHistory(metaclass=_Singleton): '''List-like singleton container that contains TransactionReceipt objects. @@ -17,6 +18,8 @@ class TxHistory(metaclass=_Singleton): def __init__(self): self._list = [] + self._revert_lock = False + Rpc()._objects.append(self) def __bool__(self): return bool(self._list) @@ -37,9 +40,10 @@ def _reset(self): self._list.clear() def _revert(self): + if self._revert_lock: + return height = web3.eth.blockNumber - for tx in [i for i in self._list if i.block_number > height]: - self._list.remove(tx) + self._list = [i for i in self._list if i.block_number <= height] def _console_repr(self): return str(self._list) @@ -47,6 +51,9 @@ def _console_repr(self): def _add_tx(self, tx): self._list.append(tx) + def clear(self): + self._list.clear() + def copy(self): '''Returns a shallow copy of the object as a list''' return self._list.copy() @@ -71,6 +78,7 @@ class _ContractHistory(metaclass=_Singleton): def __init__(self): self._dict = {} + Rpc()._objects.append(self) def _reset(self): self._dict.clear() diff --git a/brownie/network/rpc.py b/brownie/network/rpc.py index 71f0f74be..f40e36000 100644 --- a/brownie/network/rpc.py +++ b/brownie/network/rpc.py @@ -4,15 +4,13 @@ import psutil from subprocess import DEVNULL, PIPE import sys -from threading import Thread import time from .web3 import Web3 -from .account import Accounts -from .history import TxHistory, _ContractHistory + from brownie.types.types import _Singleton -from brownie.exceptions import RPCProcessError, RPCConnectionError -from brownie._config import CONFIG +from brownie.exceptions import RPCProcessError, RPCConnectionError, RPCRequestError + web3 = Web3() @@ -29,7 +27,9 @@ def __init__(self): self._rpc = None self._time_offset = 0 self._snapshot_id = False + self._internal_id = False self._reset_id = False + self._objects = [] atexit.register(self._at_exit) def _at_exit(self): @@ -74,13 +74,13 @@ def launch(self, cmd): raise RPCProcessError(cmd, self._rpc, uri) # check that web3 can connect if not web3.providers: - _reset() + self._reset() return for i in range(50): time.sleep(0.05) if web3.isConnected(): self._reset_id = self._snap() - _reset() + self._reset() return rpc = self._rpc self.kill(False) @@ -104,7 +104,7 @@ def attach(self, laddr): self._rpc = psutil.Process(proc.pid) if web3.providers: self._reset_id = self._snap() - _reset() + self._reset() def kill(self, exc=True): '''Terminates the RPC process and all children with SIGKILL. @@ -125,15 +125,18 @@ def kill(self, exc=True): self._snapshot_id = False self._reset_id = False self._rpc = None - _reset() + self._reset() def _request(self, *args): if not self.is_active(): raise SystemError("RPC is not active.") try: - return web3.providers[0].make_request(*args)['result'] + response = web3.providers[0].make_request(*args) + if 'result' in response: + return response['result'] except IndexError: raise RPCConnectionError("Web3 is not connected.") + raise RPCRequestError(response['error']['message']) def _snap(self): return self._request("evm_snapshot", []) @@ -144,9 +147,17 @@ def _revert(self, id_): self._request("evm_revert", [id_]) id_ = self._snap() self.sleep(0) - _revert() + for i in self._objects: + if web3.eth.blockNumber == 0: + i._reset() + else: + i._revert() return id_ + def _reset(self): + for i in self._objects: + i._reset() + def is_active(self): '''Returns True if Rpc client is currently active.''' if not self._rpc: @@ -196,23 +207,27 @@ def revert(self): '''Reverts the EVM to the most recently taken snapshot.''' if not self._snapshot_id: raise ValueError("No snapshot set") + self._internal_id = None self._snapshot_id = self._revert(self._snapshot_id) return "Block height reverted to {}".format(web3.eth.blockNumber) def reset(self): '''Reverts the EVM to the genesis state.''' self._snapshot_id = None + self._internal_id = None self._reset_id = self._revert(self._reset_id) return "Block height reset to 0" + def _internal_snap(self): + if not self._internal_id: + self._internal_id = self._snap() -def _reset(): - TxHistory()._reset() - _ContractHistory()._reset() - Accounts()._reset() + def _internal_clear(self): + self._internal_id = None - -def _revert(): - TxHistory()._revert() - _ContractHistory()._revert() - Accounts()._revert() + def _internal_revert(self): + self._request("evm_revert", [self._internal_id]) + self._internal_id = None + self.sleep(0) + for i in self._objects: + i._revert() diff --git a/brownie/network/transaction.py b/brownie/network/transaction.py index 046d9728d..de9235ec0 100644 --- a/brownie/network/transaction.py +++ b/brownie/network/transaction.py @@ -35,6 +35,7 @@ web3 = Web3() sources = Sources() + class TransactionReceipt: '''Attributes and methods relating to a broadcasted transaction. @@ -152,17 +153,18 @@ def _await_confirm(self, silent, callback): elif CONFIG['logging']['tx']: print( ("{1} confirmed {2}- {0[key]}block{0}: {0[value]}{3}{0} " - "{0[key]}gas used{0}: {0[value]}{4}{0} ({0[value]}{5:.2%}{0})").format( - color, - self.fn_name or "Transaction", - "" if self.status else "({0[error]}{1}{0}) ".format( + "{0[key]}gas used{0}: {0[value]}{4}{0} ({0[value]}{5:.2%}{0})").format( color, - self.revert_msg or "reverted" - ), - self.block_number, - self.gas_used, - self.gas_used / self.gas_limit - )) + self.fn_name or "Transaction", + "" if self.status else "({0[error]}{1}{0}) ".format( + color, + self.revert_msg or "reverted" + ), + self.block_number, + self.gas_used, + self.gas_used / self.gas_limit + ) + ) if receipt['contractAddress']: print("{1} deployed at: {0[value]}{2}{0}".format( color, @@ -182,10 +184,15 @@ def __hash__(self): return hash(self.txid) def __getattr__(self, attr): - if attr not in ('events', 'return_value', 'revert_msg', 'trace', '_trace'): - raise AttributeError( - "'TransactionReceipt' object has no attribute '{}'".format(attr) - ) + if attr not in ( + 'events', + 'modified_state', + 'return_value', + 'revert_msg', + 'trace', + '_trace' + ): + raise AttributeError("'TransactionReceipt' object has no attribute '{}'".format(attr)) if self.status == -1: return None if self._trace is None: @@ -200,7 +207,9 @@ def info(self): if self.contract_address: line = "New Contract Address{0}: {0[value]}{1}".format(color, self.contract_address) else: - line = "To{0}: {0[value]}{1.receiver}{0}\n{0[key]}Value{0}: {0[value]}{1.value}".format(color, self) + line = "To{0}: {0[value]}{1.receiver}{0}\n{0[key]}Value{0}: {0[value]}{1.value}".format( + color, self + ) if self.input != "0x00": line += "\n{0[key]}Function{0}: {0[value]}{1}".format(color, self.fn_name) print(TX_INFO.format( @@ -217,7 +226,7 @@ def info(self): print(" Events In This Transaction\n --------------------------") for event in self.events: print(" "+color('bright yellow')+event.name+color()) - for k,v in event.items(): + for k, v in event.items(): print(" {0[key]}{1}{0}: {0[value]}{2}{0}".format( color, k, v )) @@ -230,6 +239,7 @@ def _get_trace(self): self.revert_msg = None self._trace = [] if (self.input == "0x" and self.gas_used == 21000) or self.contract_address: + self.modified_state = False self.trace = [] return trace = web3.providers[0].make_request( @@ -237,19 +247,21 @@ def _get_trace(self): [self.txid, {}] ) if 'error' in trace: + self.modified_state = None raise ValueError(trace['error']['message']) self._trace = trace = trace['result']['structLogs'] if self.status: # get return value + self.modified_state = bool(next((i for i in trace if i['op'] == "SSTORE"), False)) log = trace[-1] if log['op'] != "RETURN": return - c = self.contract_address or self.receiver - if type(c) is str: + contract = self.contract_address or self.receiver + if type(contract) is str: return abi = [ i['type'] for i in - getattr(c, self.fn_name.split('.')[-1]).abi['outputs'] + getattr(contract, self.fn_name.split('.')[-1]).abi['outputs'] ] offset = int(log['stack'][-1], 16) * 2 length = int(log['stack'][-2], 16) * 2 @@ -262,10 +274,11 @@ def _get_trace(self): else: self.return_value = KwargTuple( self.return_value, - getattr(c, self.fn_name.split('.')[-1]).abi + getattr(contract, self.fn_name.split('.')[-1]).abi ) else: # get revert message + self.modified_state = False self.revert_msg = "" if trace[-1]['op'] == "REVERT": offset = int(trace[-1]['stack'][-1], 16) * 2 @@ -288,17 +301,17 @@ def _evaluate_trace(self): self.trace = trace = self._trace if not trace: return - c = self.contract_address or self.receiver - last = {0: { - 'address': c.address, - 'contract': c._name, + contract = self.contract_address or self.receiver + last_map = {0: { + 'address': contract.address, + 'contract': contract, 'fn': [self.fn_name.split('.')[-1]], }} - pc = c._build['pcMap'][0] + pc = contract._build['pcMap'][0] trace[0].update({ - 'address': last[0]['address'], - 'contractName': last[0]['contract'], - 'fn': last[0]['fn'][-1], + 'address': last_map[0]['address'], + 'contractName': last_map[0]['contract']._name, + 'fn': last_map[0]['fn'][-1], 'jumpDepth': 1, 'source': { 'filename': pc['contract'], @@ -310,23 +323,24 @@ def _evaluate_trace(self): # if depth has increased, tx has called into a different contract if trace[i]['depth'] > trace[i-1]['depth']: address = web3.toChecksumAddress(trace[i-1]['stack'][-2][-40:]) - c = _contracts.find(address) + contract = _contracts.find(address) stack_idx = -4 if trace[i-1]['op'] in ('CALL', 'CALLCODE') else -3 memory_idx = int(trace[i-1]['stack'][stack_idx], 16) * 2 sig = "0x" + "".join(trace[i-1]['memory'])[memory_idx:memory_idx+8] - last[trace[i]['depth']] = { + last_map[trace[i]['depth']] = { 'address': address, - 'contract': c._name, - 'fn': [next((k for k, v in c.signatures.items() if v == sig), "")], + 'contract': contract, + 'fn': [contract.get_method(sig) or ""], } + last = last_map[trace[i]['depth']] + contract = last['contract'] trace[i].update({ - 'address': last[trace[i]['depth']]['address'], - 'contractName': last[trace[i]['depth']]['contract'], - 'fn': last[trace[i]['depth']]['fn'][-1], - 'jumpDepth': len(set(last[trace[i]['depth']]['fn'])) + 'address': last['address'], + 'contractName': contract._name, + 'fn': last['fn'][-1], + 'jumpDepth': len(set(last['fn'])) }) - c = _contracts.find(trace[i]['address']) - pc = c._build['pcMap'][trace[i]['pc']] + pc = contract._build['pcMap'][trace[i]['pc']] trace[i]['source'] = { 'filename': pc['contract'], 'start': pc['start'], @@ -334,15 +348,10 @@ def _evaluate_trace(self): } # jump 'i' is moving into an internal function if pc['jump'] == 'i': - source = c._build['source'][pc['start']:pc['stop']] - if source[:7] not in ("library", "contrac") and "(" in source: - fn = source[:source.index('(')].split('.')[-1] - else: - fn = last[trace[i]['depth']]['fn'][-1] - last[trace[i]['depth']]['fn'].append(fn) + last['fn'].append(pc['fn'] or last['fn'][-1]) # jump 'o' is coming out of an internal function - elif pc['jump'] == "o" and len(last[trace[i]['depth']]['fn']) > 1: - last[trace[i]['depth']]['fn'].pop() + elif pc['jump'] == "o" and len(['fn']) > 1: + del last['fn'][-1] def call_trace(self): '''Displays the sequence of contracts and functions called while @@ -375,7 +384,6 @@ def error(self, pad=3): idx -= 1 continue span = (self.trace[idx]['source']['start'], self.trace[idx]['source']['stop']) - c = _contracts.find(self.trace[idx]['address']) if sources.get_type(self.trace[idx]['contractName']) in ("contract", "library"): idx -= 1 continue diff --git a/brownie/project/__init__.py b/brownie/project/__init__.py index a8c1ee89d..2dc29ed1b 100644 --- a/brownie/project/__init__.py +++ b/brownie/project/__init__.py @@ -17,7 +17,7 @@ project = sys.modules[__name__] -FOLDERS = ["contracts", "scripts", "tests"] +FOLDERS = ["contracts", "scripts", "reports", "tests"] def check_for_project(path): @@ -46,7 +46,9 @@ def new(path=".", ignore_subfolder=False): if not ignore_subfolder: check = check_for_project(path) if check and check != path: - raise SystemError("Cannot make a new project inside the subfolder of an existing project.") + raise SystemError( + "Cannot make a new project inside the subfolder of an existing project." + ) for folder in [i for i in FOLDERS]: path.joinpath(folder).mkdir(exist_ok=True) if not path.joinpath('brownie-config.json').exists(): @@ -76,6 +78,8 @@ def load(path=None): raise SystemError("Could not find brownie project") path = Path(path).resolve() CONFIG['folders']['project'] = str(path) + for folder in [i for i in FOLDERS]: + path.joinpath(folder).mkdir(exist_ok=True) sys.path.insert(0, str(path)) load_project_config() compiler.set_solc_version() diff --git a/brownie/project/build.py b/brownie/project/build.py index beda97e36..b09149329 100644 --- a/brownie/project/build.py +++ b/brownie/project/build.py @@ -1,7 +1,6 @@ #!/usr/bin/python3 import ast -from copy import deepcopy from hashlib import sha1 import importlib.util import json @@ -48,6 +47,12 @@ def __init__(self): self._build = {} self._path = None + def __getitem__(self, contract_name): + return self._build[contract_name.replace('.json', '')] + + def __contains__(self, contract_name): + return contract_name.replace('.json', '') in self._build + def _load(self): self. _path = Path(CONFIG['folders']['project']).joinpath('build/contracts') # check build paths @@ -63,7 +68,7 @@ def _load(self): data, self._path.joinpath("{}.json".format(name)).open('w'), sort_keys=True, - indent=4, + indent=2, default=sorted ) self._build.update(build_json) @@ -78,6 +83,7 @@ def _load_build_data(self): set(BUILD_KEYS).issubset(build_json) and Path(build_json['sourcePath']).exists() ): + build_json['pcMap'] = dict((int(k), v) for k, v in build_json['pcMap'].items()) self._build[path.stem] = build_json continue except json.JSONDecodeError: @@ -119,11 +125,8 @@ def _check_coverage_hashes(self): coverage_json.unlink() break - def __getitem__(self, contract_name): - return deepcopy(self._build[contract_name.replace('.json','')]) - def items(self): - return deepcopy(self._build).items() + return self._build.items() def get_ast_hash(script_path): diff --git a/brownie/project/compiler.py b/brownie/project/compiler.py index 66eeb009d..d9765aad9 100644 --- a/brownie/project/compiler.py +++ b/brownie/project/compiler.py @@ -2,7 +2,6 @@ from hashlib import sha1 from pathlib import Path -import re import solcx from .sources import Sources @@ -50,7 +49,9 @@ def compile_contracts(contract_paths): CONFIG['solc']['optimize'] else "Disabled" )) - print("\n".join(" - {}...".format(Path(i).name) for i in contract_paths)) + base = Path(CONFIG['folders']['project']) + contract_paths = [Path(i).resolve().relative_to(base) for i in contract_paths] + print("\n".join(" - {}...".format(i.name) for i in contract_paths)) input_json = STANDARD_JSON.copy() input_json['sources'] = dict(( str(i), @@ -80,7 +81,7 @@ def _compile_and_format(input_json): compiled = _generate_pcMap(compiled) result = {} - for filename, name in [(k,x) for k,v in compiled['contracts'].items() for x in v]: + for filename, name in [(k, v) for k in input_json['sources'] for v in compiled['contracts'][k]]: data = compiled['contracts'][filename][name] evm = data['evm'] ref = [ @@ -94,9 +95,10 @@ def _compile_and_format(input_json): n[:36], evm['bytecode']['object'][loc+40:] ) + all_paths = sorted(set(v['contract'] for v in evm['pcMap'].values() if v['contract'])) result[name] = { 'abi': data['abi'], - 'allSourcePaths': sorted(set(i['contract'] for i in evm['pcMap'] if i['contract'])), + 'allSourcePaths': all_paths, 'ast': compiled['sources'][filename]['ast'], 'bytecode': evm['bytecode']['object'], 'bytecodeSha1': sha1(evm['bytecode']['object'][:-68].encode()).hexdigest(), @@ -106,14 +108,15 @@ def _compile_and_format(input_json): 'deployedSourceMap': evm['deployedBytecode']['sourceMap'], # 'networks': {}, 'opcodes': evm['deployedBytecode']['opcodes'], + 'pcMap': evm['pcMap'], 'sha1': sources.get_hash(name), 'source': input_json['sources'][filename]['content'], 'sourceMap': evm['bytecode']['sourceMap'], 'sourcePath': filename, - 'type': sources.get_type(name), - 'pcMap': evm['pcMap'] + 'type': sources.get_type(name) } result[name]['coverageMap'] = _generate_coverageMap(result[name]) + result[name]['coverageMapTotals'] = _generate_coverageMapTotals(result[name]['coverageMap']) return result @@ -135,9 +138,8 @@ def _generate_pcMap(compiled): bytecode = compiled['contracts'][filename][name]['evm']['deployedBytecode'] if not bytecode['object']: - compiled['contracts'][filename][name]['evm']['pcMap'] = [] + compiled['contracts'][filename][name]['evm']['pcMap'] = {} continue - pcMap = [] opcodes = bytecode['opcodes'] source_map = bytecode['sourceMap'] while True: @@ -153,124 +155,84 @@ def _generate_pcMap(compiled): last = source_map.split(';')[0].split(':') for i in range(3): last[i] = int(last[i]) - pcMap.append({ + pcMap = {0: { 'start': last[0], 'stop': last[0]+last[1], 'op': opcodes.pop(), 'contract': id_map[last[2]], 'jump': last[3], - 'pc': 0 - }) + 'fn': False + }} pcMap[0]['value'] = opcodes.pop() for value in source_map.split(';')[1:]: + if pcMap[pc]['op'][:4] == "PUSH": + pc += int(pcMap[pc]['op'][4:]) pc += 1 - if pcMap[-1]['op'][:4] == "PUSH": - pc += int(pcMap[-1]['op'][4:]) if value: value = (value+":::").split(':')[:4] for i in range(3): value[i] = int(value[i] or last[i]) value[3] = value[3] or last[3] last = value - pcMap.append({ + contract = id_map[last[2]] if last[2] != -1 else False + pcMap[pc] = { 'start': last[0], 'stop': last[0]+last[1], 'op': opcodes.pop(), - 'contract': id_map[last[2]] if last[2] != -1 else False, + 'contract': contract, 'jump': last[3], - 'pc': pc - }) + 'fn': sources.get_fn(contract, last[0], last[0]+last[1]) + } if opcodes[-1][:2] == "0x": - pcMap[-1]['value'] = opcodes.pop() + pcMap[pc]['value'] = opcodes.pop() compiled['contracts'][filename][name]['evm']['pcMap'] = pcMap return compiled def _generate_coverageMap(build): - """Given the compiled project as supplied by compiler.compile_contracts(), - returns the function and line based coverage maps for unit test coverage - evaluation. + """Adds coverage data to a build json. - A coverage map item is structured as follows: + A new key 'coverageMap' is created, structured as follows: { - "/path/to/contract/file.sol":{ - "functionName":{ - "fn": {}, - "line": [{}, {}, {}], - "total": int - } + "/path/to/contract/file.sol": { + "functionName": [{ + 'jump': pc of the JUMPI instruction, if it is a jump + 'start': source code start offest + 'stop': source code stop offset + }], } } - Each dict in fn/line is as follows: - - { - 'jump': pc of the JUMPI instruction, if it is a jump - 'pc': list of opcode program counters tied to the map item - 'start': source code start offset - 'stop': source code stop offset - } - """ - - fn_map = dict((x, {}) for x in build['allSourcePaths']) - - for i in _isolate_functions(build): - fn_map[i.pop('contract')][i.pop('method')] = {'fn': i, 'line': []} + Relevent items in the pcMap also have a 'coverageIndex' added that corresponds + to an entry in the coverageMap.""" line_map = _isolate_lines(build) if not line_map: return {} - # future me - i'm sorry for this line - for source, fn_name, fn in [(k, x, v[x]['fn']) for k, v in fn_map.items() for x in v]: - for ln in [ - i for i in line_map if i['contract'] == source and - i['start'] == fn['start'] and i['stop'] == fn['stop'] - ]: - # remove duplicate mappings - line_map.remove(ln) - for ln in [ - i for i in line_map if i['contract'] == source and - i['start'] >= fn['start'] and i['stop'] <= fn['stop'] - ]: - # apply method names to line mappings - line_map.remove(ln) - fn_map[ln.pop('contract')][fn_name]['line'].append(ln) - fn_map[source][fn_name]['total'] = sum([1 if not i['jump'] else 2 for i in fn_map[source][fn_name]['line']]) - return fn_map - - -def _isolate_functions(compiled): - '''Identify function level coverage map items.''' - pcMap = compiled['pcMap'] - fn_map = {} - for op in _oplist(pcMap, "JUMPDEST"): - s = _get_source(op) - if s[:8] in ("contract", "library ", "interfac"): + final = dict((i, {}) for i in set(i['contract'] for i in line_map)) + for i in line_map: + fn = sources.get_fn(i['contract'], i['start'], i['stop']) + if not fn: continue - if s[:8] == "function": - fn = s[9:s.index('(')] - elif " public " in s: - fn = s[s.index(" public ")+8:].split(' =')[0].strip() - else: - continue - if fn not in fn_map: - fn_map[fn] = _base(op) - fn_map[fn]['method'] = fn - fn_map[fn]['pc'].add(op['pc']) - - fn_map = _sort(fn_map.values()) - if not fn_map: - return [] - for op in _oplist(pcMap): - try: - f = _next(fn_map, op) - except StopIteration: - continue - if op['stop'] > f['stop']: - continue - f['pc'].add(op['pc']) - return fn_map + final[i['contract']].setdefault(fn, []).append({ + 'jump': i['jump'], + 'start': i['start'], + 'stop': i['stop'] + }) + for pc in i['pc']: + build['pcMap'][pc]['coverageIndex'] = len(final[i['contract']][fn]) - 1 + return final + + +def _generate_coverageMapTotals(coverage_map): + totals = {'total': 0} + for path, fn_name in [(k, x) for k, v in coverage_map.items() for x in v]: + maps = coverage_map[path][fn_name] + count = len([i for i in maps if not i['jump']]) + len([i for i in maps if i['jump']])*2 + totals[fn_name] = count + totals['total'] += count + return totals def _isolate_lines(compiled): @@ -284,7 +246,7 @@ def _isolate_lines(compiled): line_map = {} # find all the JUMPI opcodes - for i in [pcMap.index(i) for i in _oplist(pcMap, "JUMPI")]: + for i in [k for k, v in pcMap.items() if v['contract'] and v['op'] == "JUMPI"]: op = pcMap[i] if op['contract'] not in line_map: line_map[op['contract']] = [] @@ -294,39 +256,46 @@ def _isolate_lines(compiled): try: # JUMPI is to the closest previous opcode that has # a different source offset and is not a JUMPDEST - req = next( - x for x in pcMap[i-2::-1] if x['contract'] and - x['op'] != "JUMPDEST" and - x['start'] + x['stop'] != op['start'] + op['stop'] + pc = next( + x for x in range(i - 4, 0, -1) if x in pcMap and + pcMap[x]['contract'] and pcMap[x]['op'] != "JUMPDEST" and + (pcMap[x]['start'], pcMap[x]['stop']) != (op['start'], op['stop']) ) except StopIteration: continue - line_map[op['contract']].append(_base(req)) - line_map[op['contract']][-1].update({'jump': op['pc']}) + line_map[op['contract']].append(_base(pc, pcMap[pc])) + line_map[op['contract']][-1].update({'jump': i}) # analyze all the opcodes - for op in _oplist(pcMap): + for pc, op in [(i, pcMap[i]) for i in sorted(pcMap)]: # ignore code that spans multiple lines - if ';' in _get_source(op): + if not op['contract'] or ';' in _get_source(op): continue if op['contract'] not in line_map: line_map[op['contract']] = [] # find existing related coverage map item, make a new one if none exists try: - ln = _next(line_map[op['contract']], op) + ln = next( + i for i in line_map[op['contract']] if + i['contract'] == op['contract'] and + i['start'] <= op['start'] < i['stop'] + ) except StopIteration: - line_map[op['contract']].append(_base(op)) + line_map[op['contract']].append(_base(pc, op)) continue if op['stop'] > ln['stop']: # if coverage map item is a jump, do not modify the source offsets if ln['jump']: continue ln['stop'] = op['stop'] - ln['pc'].add(op['pc']) + ln['pc'].add(pc) # sort the coverage map and merge overlaps where possible for contract in line_map: - line_map[contract] = _sort(line_map[contract]) + line_map[contract] = sorted( + line_map[contract], + key=lambda k: (k['contract'], k['start'], k['stop']) + ) ln_map = line_map[contract] i = 0 while True: @@ -355,28 +324,11 @@ def _get_source(op): return sources[op['contract']][op['start']:op['stop']] -def _next(coverage_map, op): - '''Given a coverage map and an item from pcMap, returns the related - coverage map item (based on source offset overlap).''' - return next( - i for i in coverage_map if i['contract'] == op['contract'] and - i['start'] <= op['start'] < i['stop'] - ) - - -def _sort(list_): - return sorted(list_, key=lambda k: (k['contract'], k['start'], k['stop'])) - - -def _oplist(pcMap, op=None): - return [i for i in pcMap if i['contract'] and (not op or op == i['op'])] - - -def _base(op): +def _base(pc, op): return { 'contract': op['contract'], 'start': op['start'], 'stop': op['stop'], - 'pc': set([op['pc']]), + 'pc': set([pc]), 'jump': False } diff --git a/brownie/project/sources.py b/brownie/project/sources.py index f3c26684a..8f60cc70b 100644 --- a/brownie/project/sources.py +++ b/brownie/project/sources.py @@ -1,8 +1,8 @@ #!/usr/bin/python3 +from hashlib import sha1 from pathlib import Path import re -from hashlib import sha1 from brownie.types.types import _Singleton from brownie._config import CONFIG @@ -12,36 +12,84 @@ class Sources(metaclass=_Singleton): def __init__(self): self._source = {} + self._uncommented_source = {} + self._comment_offsets = {} self._path = None self._data = {} - self._inheritance_map = {} self._string_iter = 1 - + + def __getitem__(self, key): + if key in self._data: + return self._source[self._data[key]['sourcePath']] + return self._source[str(key)] + def _load(self): - self. _path = Path(CONFIG['folders']['project']).joinpath('contracts') - for path in [i for i in self._path.glob('**/*.sol') if "/_" not in str(i)]: - self._source[str(path)] = path.open().read() + base_path = Path(CONFIG['folders']['project']) + self. _path = base_path.joinpath('contracts') + for path in [i.relative_to(base_path) for i in self._path.glob('**/*.sol')]: + if "/_" in str(path): + continue + source = path.open().read() + self._source[str(path)] = source + self._remove_comments(path) self._get_contract_data(path) - for name, inherited in [(k, v['inherited'].copy()) for k,v in self._data.items()]: + for name, inherited in [(k, v['inherited'].copy()) for k, v in self._data.items()]: self._data[name]['inherited'] = self._recursive_inheritance(inherited) + def _remove_comments(self, path): + source = self._source[str(path)] + offsets = [(0, 0)] + pattern = r"((?:\s*\/\/[^\n]*)|(?:\/\*[\s\S]*?\*\/))" + for match in re.finditer(pattern, source): + offsets.append(( + match.start() - offsets[-1][1], + match.end() - match.start() + offsets[-1][1] + )) + self._uncommented_source[str(path)] = re.sub(pattern, "", source) + self._comment_offsets[str(path)] = offsets[::-1] + def _get_contract_data(self, path): contracts = re.findall( - "((?:contract|library|interface)[\s\S]*?})\s*(?=contract|library|interface|$)", - self.remove_comments(path) + r"((?:contract|library|interface)[^;{]*{[\s\S]*?})\s*(?=contract|library|interface|$)", + self._uncommented_source[str(path)] ) for source in contracts: type_, name, inherited = re.findall( - "\s*(contract|library|interface) (\S*) (?:is (.*?)|)(?: *{)", + r"\s*(contract|library|interface) (\S*) (?:is (.*?)|)(?: *{)", source )[0] inherited = set(i.strip() for i in inherited.split(', ') if i) + offset = self._uncommented_source[str(path)].index(source) self._data[name] = { - 'sourcePath': path, + 'sourcePath': str(path), 'type': type_, - 'inherited': inherited.union(re.findall("(?:;|{)\s*using *(\S*)(?= for)", source)), - 'sha1': sha1(source.encode()).hexdigest() + 'inherited': inherited.union(re.findall(r"(?:;|{)\s*using *(\S*)(?= for)", source)), + 'sha1': sha1(source.encode()).hexdigest(), + 'fn_offsets': [], + 'offset': ( + self._commented_offset(path, offset), + self._commented_offset(path, offset + len(source)) + ) } + if type_ == "interface": + continue + fn_offsets = [] + for idx, pattern in enumerate(( + # matches functions + r"function\s*(\w*)[^{;]*{[\s\S]*?}(?=\s*function|\s*})", + # matches public variables + r"(?:{|;)\s*(?!function)(\w[^;]*(?:public\s*constant|public)\s*(\w*)[^{;]*)(?=;)" + )): + for match in re.finditer(pattern, source): + fn_offsets.append(( + name+"."+(match.groups()[idx] or ""), + self._commented_offset(path, match.start(idx) + offset), + self._commented_offset(path, match.end(idx) + offset) + )) + self._data[name]['fn_offsets'] = sorted(fn_offsets, key=lambda k: k[1], reverse=True) + + def _commented_offset(self, path, offset): + return offset + next(i[1] for i in self._comment_offsets[str(path)] if i[0] <= offset) def _recursive_inheritance(self, inherited): final = set(inherited) @@ -49,13 +97,6 @@ def _recursive_inheritance(self, inherited): final |= self._recursive_inheritance(self._data[name]['inherited']) return final - def remove_comments(self, path): - return re.sub( - "((?:\s*\/\/[^\n]*)|(?:\/\*[\s\S]*?\*\/))", - "", - self._source[str(path)] - ) - def get_hash(self, contract_name): return self._data[contract_name]['sha1'] @@ -64,18 +105,39 @@ def get_path(self, contract_name): def get_type(self, contract_name): return self._data[contract_name]['type'] - - def inheritance_map(self): - return dict((k, v['inherited'].copy()) for k,v in self._data.items()) - def __getitem__(self, key): - if key in self._data: - return self._source[self._data[key]['sourcePath']] - return self._source[str(key)] + def get_fn(self, name, start, stop): + if name not in self._data: + name = next(( + k for k, v in self._data.items() if v['sourcePath'] == str(name) and + v['offset'][0] <= start <= stop <= v['offset'][1] + ), False) + if not name: + return False + offsets = self._data[name]['fn_offsets'] + if start < offsets[-1][1]: + return False + offset = next(i for i in offsets if start >= i[1]) + return False if stop > offset[2] else offset[0] + + def get_fn_offset(self, name, fn_name): + try: + if name not in self._data: + name = next( + k for k, v in self._data.items() if v['sourcePath'] == str(name) and + fn_name in [i[0] for i in v['fn_offsets']] + ) + return next(i for i in self._data[name]['fn_offsets'] if i[0] == fn_name)[1:3] + except StopIteration: + raise ValueError("Unknown function '{}' in contract {}".format(fn_name, name)) + + def inheritance_map(self): + return dict((k, v['inherited'].copy()) for k, v in self._data.items()) def add_source(self, source): path = "".format(self._string_iter) self._source[path] = source + self._remove_comments(path) self._get_contract_data(path) self._string_iter += 1 - return path \ No newline at end of file + return path diff --git a/brownie/test/check.py b/brownie/test/check.py index 287081fad..6534baf16 100644 --- a/brownie/test/check.py +++ b/brownie/test/check.py @@ -92,10 +92,10 @@ def event_fired(tx, name, count=None, values=None): return if type(values) is dict: values = [values] - if len(values) != len(events): + if len(values) != len(tx.events): raise AssertionError( "Event {} - {} events fired, {} values to match given".format( - name, len(events), len(values) + name, len(tx.events), len(values) ) ) for i in range(len(values)): diff --git a/brownie/test/coverage.py b/brownie/test/coverage.py index 3f4b1c2ab..496b7f8c9 100644 --- a/brownie/test/coverage.py +++ b/brownie/test/coverage.py @@ -2,129 +2,218 @@ import json from pathlib import Path +import re -from brownie.project.build import Build +from brownie.project import Build, Sources build = Build() +sources = Sources() def analyze_coverage(history): - coverage_map = {} + '''Given a list of TransactionReceipt objects, analyzes test coverage and + returns a coverage evaluation dict. + ''' coverage_eval = {} + coverage_map = {} + pcMap = {} for tx in history: if not tx.receiver: continue + tx_eval = {} for i in range(len(tx.trace)): t = tx.trace[i] pc = t['pc'] name = t['contractName'] - source = t['source']['filename'] - if not name or not source: + path = t['source']['filename'] + if not name or not path or name not in build: continue - if name not in coverage_map: + + # prevent repeated requests to build object + if name not in pcMap: + pcMap[name] = build[name]['pcMap'] coverage_map[name] = build[name]['coverageMap'] coverage_eval[name] = dict((i, {}) for i in coverage_map[name]) - try: - # find the function map item and record the tx - fn = next(v for k, v in coverage_map[name][source].items() if pc in v['fn']['pc']) - fn['fn'].setdefault('tx',set()).add(tx) - if t['op'] != "JUMPI": - # if not a JUMPI, find the line map item and record - ln = next(i for i in fn['line'] if pc in i['pc']) - for key in ('tx', 'true', 'false'): - ln.setdefault(key, set()) - ln['tx'].add(tx) - continue - # if a JUMPI, we need to have hit the jump pc AND a related opcode - ln = next(i for i in fn['line'] if pc == i['jump']) - for key in ('tx', 'true', 'false'): - ln.setdefault(key, set()) - if tx not in ln['tx']: - continue - # if the next opcode is not pc+1, the JUMPI was executed truthy - key = 'false' if tx.trace[i+1]['pc'] == pc+1 else 'true' - ln[key].add(tx) - # pc didn't exist in map - except StopIteration: - continue + if name not in tx_eval: + tx_eval[name] = dict((i, {}) for i in coverage_map[name]) - for contract, source, fn_name, maps in [(k,w,y,z) for k,v in coverage_map.items() for w,x in v.items() for y,z in x.items()]: - fn = maps['fn'] - if 'tx' not in fn or not fn['tx']: - coverage_eval[contract][source][fn_name] = {'pct':0} - continue - for ln in maps['line']: - if 'tx' not in ln: - ln['count'] = 0 + fn = pcMap[name][pc]['fn'] + if not fn: continue - if ln['jump']: - ln['jump'] = [len(ln['true']), len(ln['false'])] - ln['count'] = len(ln['tx']) - if not [i for i in maps['line'] if i['count']]: - coverage_eval[contract][source][fn_name] = {'pct':0} - continue - count = 0 - coverage = { - 'line': set(), - 'true': set(), - 'false': set() - } - for c,i in enumerate(maps['line']): - if not i['count']: + coverage_eval[name][path].setdefault(fn, {'tx': set(), 'true': set(), 'false': set()}) + tx_eval[name][path].setdefault(fn, set()) + if t['op'] != "JUMPI": + if 'coverageIndex' not in pcMap[name][pc]: + continue + # if not a JUMPI, record at the coverage map index + idx = pcMap[name][pc]['coverageIndex'] + if coverage_map[name][path][fn][idx]['jump']: + tx_eval[name][path][fn].add(pcMap[name][pc]['coverageIndex']) + else: + coverage_eval[name][path][fn]['tx'].add(pcMap[name][pc]['coverageIndex']) + continue + # if a JUMPI, check that we hit the jump AND the related coverage map + try: + idx = coverage_map[name][path][fn].index( + next(i for i in coverage_map[name][path][fn] if i['jump'] == pc) + ) + except StopIteration: continue - if not i['jump'] or False not in i['jump']: - coverage['line'].add(c) - count += 2 if i['jump'] else 1 + if idx not in tx_eval[name][path][fn] or idx in coverage_eval[name][path][fn]['tx']: continue - if i['jump'][0]: - coverage['true'].add(c) - count += 1 - if i['jump'][1]: - coverage['false'].add(c) - count += 1 - if count == maps['total']: - coverage_eval[contract][source][fn_name] = {'pct': 1} - else: - coverage['pct'] = round(count/maps['total'], 4) - coverage_eval[contract][source][fn_name] = coverage + key = ('false', 'true') if tx.trace[i+1]['pc'] == pc+1 else ('true', 'false') + # if the conditional evaluated both ways, record on the main eval dict + if idx not in coverage_eval[name][path][fn][key[1]]: + coverage_eval[name][path][fn][key[0]].add(idx) + continue + coverage_eval[name][path][fn][key[1]].discard(idx) + coverage_eval[name][path][fn]['tx'].add(idx) + return _calculate_pct(coverage_eval) - return coverage_eval + +def merge_coverage_eval(*coverage_eval): + '''Given a list of coverage evaluation dicts, returns an aggregated evaluation dict.''' + merged_eval = coverage_eval[0] + for coverage in coverage_eval[1:]: + for contract_name in list(coverage): + del coverage[contract_name]['pct'] + if contract_name not in merged_eval: + merged_eval[contract_name] = coverage.pop(contract_name) + continue + for source, fn_name in [(k, x) for k, v in coverage[contract_name].items() for x in v]: + f = merged_eval[contract_name][source][fn_name] + c = coverage[contract_name][source][fn_name] + if not f['pct']: + f.update(c) + continue + if not c['pct'] or f == c: + continue + if f['pct'] == 1 or c['pct'] == 1: + merged_eval[contract_name][source][fn_name] = {'pct': 1} + continue + f['true'] += c['true'] + f['false'] += c['false'] + f['tx'] = list(set(f['tx']+c['tx']+[i for i in f['true'] if i in f['false']])) + f['true'] = list(set([i for i in f['true'] if i not in f['tx']])) + f['false'] = list(set([i for i in f['false'] if i not in f['tx']])) + return _calculate_pct(merged_eval) -def merge_coverage(coverage_files): - final = {} +def merge_coverage_files(coverage_files): + '''Given a list of coverage evaluation file paths, returns an aggregated evaluation dict.''' + coverage_eval = [] for filename in coverage_files: path = Path(filename) if not path.exists(): continue - coverage = json.load(path.open())['coverage'] - for key in list(coverage): - if key not in final: - final[key] = coverage.pop(key) + coverage_eval.append(json.load(path.open())['coverage']) + return merge_coverage_eval(*coverage_eval) + + +def _calculate_pct(coverage_eval): + '''Internal method to calculate coverage percentages''' + for name in coverage_eval: + contract_count = 0 + coverage_map = build[name]['coverageMap'] + for path, fn_name in [(k, x) for k, v in coverage_map.items() for x in v]: + result = coverage_eval[name][path] + if fn_name not in result: + result[fn_name] = {'pct': 0} + continue + if 'pct' in result[fn_name] and result[fn_name]['pct'] in (0, 1): + if result[fn_name]['pct']: + contract_count += build[name]['coverageMapTotals'][fn_name] + result[fn_name] = {'pct': result[fn_name]['pct']} continue - for source, fn_name in [(k, x) for k, v in coverage[key].items() for x in v]: - f = final[key][source][fn_name] - c = coverage[key][source][fn_name] - if not c['pct']: + result = dict((k, list(v) if type(v) is set else v) for k, v in result[fn_name].items()) + coverage_eval[name][path][fn_name] = result + count = 0 + maps = coverage_map[path][fn_name] + for idx, item in enumerate(maps): + if idx in result['tx']: + count += 2 if item['jump'] else 1 continue - if f['pct'] == 1 or c['pct'] == 1: - final[key][source][fn_name] = {'pct': 1} + if not item['jump']: + continue + if idx in result['true'] or idx in result['false']: + count += 1 + contract_count += count + result['pct'] = round(count / build[name]['coverageMapTotals'][fn_name], 4) + if result['pct'] == 1: + coverage_eval[name][path][fn_name] = {'pct': 1} + pct = round(contract_count / build[name]['coverageMapTotals']['total'], 4) + coverage_eval[name]['pct'] = pct + return coverage_eval + + +def generate_report(coverage_eval): + '''Converts coverage evaluation into highlight data suitable for the GUI''' + report = { + 'highlights': {}, + 'coverage': {}, + 'sha1': {} + } + for name, coverage in coverage_eval.items(): + report['highlights'][name] = {} + report['coverage'][name] = {'pct': coverage['pct']} + for path in [i for i in coverage if i != "pct"]: + coverage_map = build[name]['coverageMap'][path] + report['highlights'][name][path] = [] + for fn_name, lines in coverage_map.items(): + # if function has 0% or 100% coverage, highlight entire function + report['coverage'][name][fn_name] = coverage[path][fn_name]['pct'] + if coverage[path][fn_name]['pct'] in (0, 1): + color = "green" if coverage[path][fn_name]['pct'] else "red" + start, stop = sources.get_fn_offset(path, fn_name) + report['highlights'][name][path].append( + (start, stop, color, "") + ) continue - _list_to_set(f,'line').update(c['line']) - if 'true' in c: - _list_to_set(f, "true").update(c['true']) - _list_to_set(f, "false").update(c['false']) - for i in f['true'].intersection(f['false']): - f['line'].add(i) - f['true'].discard(i) - f['false'].discard(i) - return final - - -def _list_to_set(obj, key): - if key in obj: - obj[key] = set(obj[key]) - else: - obj[key] = set() - return obj[key] \ No newline at end of file + # otherwise, highlight individual statements + for i, ln in enumerate(lines): + if i in coverage[path][fn_name]['tx']: + color = "green" + elif i in coverage[path][fn_name]['true']: + color = "yellow" if _evaluate_branch(path, ln) else "orange" + elif i in coverage[path][fn_name]['false']: + color = "orange" if _evaluate_branch(path, ln) else "yellow" + else: + color = "red" + report['highlights'][name][path].append( + (ln['start'], ln['stop'], color, "") + ) + return report + + +def _evaluate_branch(path, ln): + source = sources[path] + start, stop = ln['start'], ln['stop'] + try: + idx = _maxindex(source[:start]) + except Exception: + return False + + # remove comments, strip whitespace + before = source[idx:start] + for pattern in (r'\/\*[\s\S]*?\*\/', r'\/\/[^\n]*'): + for i in re.findall(pattern, before): + before = before.replace(i, "") + before = before.strip("\n\t (") + + idx = source[stop:].index(';')+len(source[:stop]) + if idx <= stop: + return False + after = source[stop:idx].split() + after = next((i for i in after if i != ")"), after[0])[0] + if ( + (before[-2:] == "if" and after == "|") or + (before[:7] == "require" and after in (")", "|", ",")) + ): + return True + return False + + +def _maxindex(source): + comp = [i for i in [";", "}", "{"] if i in source] + return max([source.rindex(i) for i in comp])+1 diff --git a/brownie/types/__init__.py b/brownie/types/__init__.py index 937e2c175..11633cfe2 100644 --- a/brownie/types/__init__.py +++ b/brownie/types/__init__.py @@ -1,3 +1,3 @@ #!/usr/bin/python3 -from .types import * \ No newline at end of file +from .types import * # noqa: F401 F403 diff --git a/brownie/types/convert.py b/brownie/types/convert.py index 0c9ff58a7..9ba08e312 100644 --- a/brownie/types/convert.py +++ b/brownie/types/convert.py @@ -46,7 +46,7 @@ def format_input(abi, inputs): "length of {}, should be {}".format(len(inputs[i]), type_) ) inputs[i] = format_input( - {'name': name, 'inputs':[{'type': base_type}] * len(inputs[i])}, + {'name': name, 'inputs': [{'type': base_type}] * len(inputs[i])}, inputs[i] ) continue @@ -114,7 +114,7 @@ def to_address(value): def to_bytes(value, type_="bytes32"): '''Convert a value to bytes''' if type(value) not in (bytes, str, int): - raise TypeError("'{}', type {}, cannot be converted to {}".format(value, type(value), type_)) + raise TypeError("'{}', type {}, cannot convert to {}".format(value, type(value), type_)) if type_ == "byte": type_ = "bytes1" if type_ != "bytes": diff --git a/brownie/types/types.py b/brownie/types/types.py index c5e2854c3..a0d26fdea 100644 --- a/brownie/types/types.py +++ b/brownie/types/types.py @@ -113,7 +113,10 @@ def __getitem__(self, key): def _update_from_args(self, values): '''Updates the dict from docopts.args''' - self.update(dict((k.lstrip("-"), v) for k,v in values.items())) + self.update(dict((k.lstrip("-"), v) for k, v in values.items())) + + def copy(self): + return FalseyDict(self) class EventDict: @@ -202,7 +205,8 @@ def __init__(self, name, event_data, 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 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] diff --git a/docs/api-network.rst b/docs/api-network.rst index d67effa27..8c665ca58 100644 --- a/docs/api-network.rst +++ b/docs/api-network.rst @@ -1171,6 +1171,17 @@ TransactionReceipt Attributes >>> tx.logs [AttributeDict({'logIndex': 0, 'transactionIndex': 0, 'transactionHash': HexBytes('0xa8afb59a850adff32548c65041ec253eb64e1154042b2e01e2cd8cddb02eb94f'), 'blockHash': HexBytes('0x0b93b4cf230c9ef92b990de9cd62611447d83d396f1b13204d26d28bd949543a'), 'blockNumber': 6, 'address': '0x79447c97b6543F6eFBC91613C655977806CB18b0', 'data': '0x0000000000000000000000006b5132740b834674c3277aafa2c27898cbe740f600000000000000000000000031d504908351d2d87f3d6111f491f0b52757b592000000000000000000000000000000000000000000000000000000000000000a', 'topics': [HexBytes('0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef')], 'type': 'mined'})] +.. py:attribute:: TransactionReceipt.modified_state + + Boolean indicating if this transaction resuled in any state changes on the blockchain. + + .. code-block:: python + + >>> tx + + >>> tx.modified_state + True + .. py:attribute:: TransactionReceipt.nonce The nonce of the transaction. diff --git a/docs/api-project.rst b/docs/api-project.rst index be91eaf15..acd478dae 100644 --- a/docs/api-project.rst +++ b/docs/api-project.rst @@ -150,10 +150,6 @@ Sources >>> from brownie.project.sources import Sources >>> s = Sources() -.. py:classmethod:: Sources.remove_comments(path) - - Given the path to a contract source file, removes comments from the source code and returns it as a string. - .. py:classmethod:: Sources.get_hash(contract_name) Returns a hash of the contract source code. This hash is generated specifically from the given contract name (not the entire containing file), after comments have been removed. @@ -166,6 +162,14 @@ Sources Returns the type of contract (contract, interface, library). +.. py:classmethod:: Sources.get_fn(name, start, stop) + + Given a contract name, start and stop offset, returns the name of the associated function. Returns ``False`` if the offset spans multiple functions. + +.. py:classmethod:: Sources.get_fn_offset(name, fn_name) + + Given a contract and function name, returns the source offsets of the function. + .. py:classmethod:: Sources.inheritance_map() Returns a set of all contracts that the given contract inherits from. diff --git a/docs/api-test.rst b/docs/api-test.rst index bf95ea673..957aeda3b 100644 --- a/docs/api-test.rst +++ b/docs/api-test.rst @@ -209,4 +209,9 @@ Module Methods .. py:method:: coverage.merge_coverage(coverage_files) - Given a list of coverage file paths, returns a single aggregated coverage report. Used by ``cli.test`` and ``gui.root`` to display coverage information. + Given a list of coverage file paths, returns a single aggregated coverage report. + +.. py:method:: coverage.generate_report(coverage_eval) + + + Given a coverage report as generated by ``analyze_coverage`` or ``merge_coverage``, returns a generic highlight report suitable for display within the Brownie GUI. diff --git a/docs/conf.py b/docs/conf.py index eaa483f72..7bb7b8fca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ # The short X.Y version version = '' # The full version, including alpha/beta/rc tags -release = 'v1.0.0b4' +release = 'v1.0.0b5' # -- General configuration --------------------------------------------------- diff --git a/docs/config.rst b/docs/config.rst index 42c2fe0bf..918a0b713 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -43,9 +43,11 @@ The following settings are available: .. py:attribute:: test - Properties that affect only affect Brownie's configuration when running scripts and tests. See Test :ref:`test_settings` for detailed information on the effects and implications of these settings. + 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``: If set to an integer, this value will over-ride the default gas limit setting when running tests. + * ``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. diff --git a/docs/coverage.rst b/docs/coverage.rst index 284b4c46e..31161bcf1 100644 --- a/docs/coverage.rst +++ b/docs/coverage.rst @@ -4,11 +4,11 @@ Checking Test Coverage ====================== -.. warning:: Test coverage evaluation is still under development. There may be undiscovered issues, particularly cases where conditional True/False evaluation is incorrect. Use common sense when viewing coverage reports and please open an issue on github if you encounter any issues. +.. warning:: Test coverage evaluation is still under development. There may be undiscovered issues, particularly cases where conditional ``True``/``False`` evaluation is incorrect. Use common sense when viewing coverage reports and please open an issue on github if you encounter any issues. Test coverage is estimated by generating a map of opcodes associated with each function and line of the smart contract source code, and then analyzing the stack trace of each transaction to see which opcodes were executed. -When analyzing coverage, contract calls are executed as transactions. This allows analysis for non state-changing methods, however it also means that calls will consume gas, increase the block height and increase the nonce of an address. You can prevent this behaviour by adding ``always_transact=False`` as a keyword argument for a test. +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: @@ -21,39 +21,51 @@ This will run all the test scripts in the ``tests/`` folder and give an estimate :: $ brownie test --coverage - Using network 'development' - Running 'ganache-cli -a 20'... - - Running transfer.py - 1 test - ✓ Deployment 'token' (0.1882s) - ✓ Transfer tokens (0.1615s) - Using network 'development' - Running 'ganache-cli -a 20'... - - Running approve_transferFrom.py - 3 tests - ✓ Deployment 'token' (0.1263s) - ✓ Set approval (0.2016s) - ✓ Transfer tokens with transferFrom (0.1375s) - ✓ transerFrom should revert (0.0486s) - - Coverage analysis complete! - - contract: Token - add - 50.0% - allowance - 0.0% - approve - 100.0% - balanceOf - 0.0% - decimals - 0.0% - name - 0.0% - sub - 75.0% - symbol - 0.0% - totalSupply - 0.0% - transfer - 100.0% - transferFrom - 100.0% - -Brownie will output a % score for each contract method, that you can use to quickly gauge your overall coverage level. - -To analyze specific test coverage, type: + 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: :: @@ -65,7 +77,7 @@ Or from the console: >>> Gui() -This will open the Brownie GUI. Then press ``C`` to display the coverage results. Relevant code will be highlighted in different colors: +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 diff --git a/docs/init.rst b/docs/init.rst index 6713dc06c..78ebd2cc6 100644 --- a/docs/init.rst +++ b/docs/init.rst @@ -12,6 +12,7 @@ This will create the following project structure within the folder: * ``build/``: Compiled contracts and test data * ``contracts/``: Contract source code +* ``reports/``: JSON report files for use in the :ref:`coverage-gui` * ``scripts/``: Scripts for deployment and interaction * ``tests/``: Scripts for testing your project * ``brownie-config.json``: Configuration file for the project diff --git a/docs/opview.png b/docs/opview.png index afccbbe86..6732ab189 100644 Binary files a/docs/opview.png and b/docs/opview.png differ diff --git a/docs/tests.rst b/docs/tests.rst index 971d9d200..ff8281fd7 100644 --- a/docs/tests.rst +++ b/docs/tests.rst @@ -49,10 +49,12 @@ You can optionally include a docstring in each test method to give more verbosit The following keyword arguments can be used to affect how a test runs: -* ``skip``: If set to ``True``, this test will not be run. +* ``skip``: If set to ``True``, this test will not be run. If set to ``coverage``, the test will only be skipped during coverage evaluation. * ``pending``: If set to ``True``, this test is expected to fail. If the test passes it will raise an ``ExpectedFailing`` exception. * ``always_transact``: If set to ``False``, calls to non state-changing methods will still execute as calls when running test coverage analysis. See :ref:`coverage` for more information. +Any arguments applied to a test module's ``setup`` method will be used as the default arguments for all that module's methods. Including ``skip=True`` on the setup method will skip the entire module. + Tests rely heavily on methods in the Brownie ``check`` module as an alternative to normal ``assert`` statements. You can read about them in the API :ref:`api_check` documentation. Example Test Script @@ -190,10 +192,19 @@ The following test configuration settings are available in ``brownie-config.json { "test": { "gas_limit": 6721975, + "broadcast_reverting_tx": true, "default_contract_owner": false } } +.. py:attribute:: gas_limit + + Replaces the default network gas limit. + +.. py:attribute:: broadcast_reverting_tx + + Replaces the default network setting for broadcasting transactions that would revert. + .. py:attribute:: default_contract_owner If ``True``, calls to contract transactions that do not specify a sender are broadcast from the same address that deployed the contract. diff --git a/setup.py b/setup.py index 6c44526f7..bd60c5526 100644 --- a/setup.py +++ b/setup.py @@ -9,28 +9,28 @@ requirements = list(map(str.strip, f.read().split("\n")))[:-1] setup( - name = 'eth-brownie', + name="eth-brownie", packages=find_packages(), - version = '1.0.0b4', - license = 'MIT', - description = 'A python framework for Ethereum smart contract deployment, testing and interaction.', - long_description = long_description, - long_description_content_type = "text/markdown", - author = 'Benjamin Hauser', - author_email = 'ben.hauser@hyperlink.technology', - url = 'https://github.com/HyperLink-Technology/brownie', - keywords = ['brownie'], - install_requires = requirements, - entry_points = {"console_scripts": ["brownie=brownie.cli.__main__:main"]}, - include_package_data = True, - python_requires='>=3.6,<4', - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Build Tools', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7' + version="1.0.0b5", + license="MIT", + description="A Python framework for Ethereum smart contract deployment, testing and interaction.", # noqa: E501 + long_description=long_description, + long_description_content_type="text/markdown", + author="Benjamin Hauser", + author_email="ben.hauser@hyperlink.technology", + url="https://github.com/HyperLink-Technology/brownie", + keywords=['brownie'], + install_requires=requirements, + entry_points={'console_scripts': ["brownie=brownie.cli.__main__:main"]}, + include_package_data=True, + python_requires=">=3.6,<4", + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7" ], -) +)