From a9ec020cac113907d1ad035a1a1f1415acb79b39 Mon Sep 17 00:00:00 2001 From: Mike Shultz Date: Fri, 22 Mar 2019 22:45:08 -0600 Subject: [PATCH] MEGACOMMIT, but all related. - adds ability to set a newly created account as default - adds `use_default_account` setting to networks.yml - accounts with this setting will autodeploy with the default network(if autodeploy_allowed is set, anyway) - standardized `sb test` exit codes - `sb test` autocompiles now - account attribute is now not required for an instance of deployer, only for certain uses - adds "stateless" test and deploy tests without using ganache Issue #77 --- solidbyte/cli/accounts.py | 28 ++- solidbyte/cli/deploy.py | 6 +- solidbyte/cli/script.py | 4 +- solidbyte/cli/test.py | 26 ++- solidbyte/common/metafile.py | 3 + solidbyte/deploy/__init__.py | 58 ++++++- solidbyte/script/__init__.py | 16 +- .../templates/templates/bare/networks.yml | 1 + .../templates/templates/erc20/networks.yml | 1 + solidbyte/testing/__init__.py | 8 + tests/test_argparse.py | 17 +- tests/test_cli.py | 161 ++++++++++++++++-- tests/test_metafile.py | 3 + 13 files changed, 284 insertions(+), 48 deletions(-) diff --git a/solidbyte/cli/accounts.py b/solidbyte/cli/accounts.py index 9a583d7..7cd864b 100644 --- a/solidbyte/cli/accounts.py +++ b/solidbyte/cli/accounts.py @@ -51,6 +51,9 @@ def add_parser_arguments(parser): dest='passphrase', help='The passphrase to use to encrypt the keyfile. Leave empty for prompt.' ) + create_parser.add_argument('-e', '--default', action='store_true', + dest="create_default", default=False, + help='Set the newly created account as default') # Set default account default_parser = subparsers.add_parser('default', help="Set the default account") @@ -68,26 +71,37 @@ def main(parser_args): network_name = parser_args.network else: network_name = None + web3 = web3c.get_web3(network_name) accts = Accounts(network_name=network_name, keystore_dir=parser_args.keystore, web3=web3) + mfile = MetaFile() if parser_args.account_command == 'create': - print("creating account...") + + log.debug("creating account...") + if parser_args.passphrase: password = parser_args.passphrase else: password = getpass('Encryption password:') + addr = accts.create_account(password) - print("Created new account: {}".format(addr)) + + log.info("Created new account: {}".format(addr)) + + if parser_args.create_default is True: + mfile.set_default_account(addr) + log.info("New account set as default.") + elif parser_args.account_command == 'default': - print("Setting default account to: {}".format(parser_args.default_address)) - metafile = MetaFile() - metafile.set_default_account(parser_args.default_address) + log.info("Setting default account to: {}".format(parser_args.default_address)) + + mfile.set_default_account(parser_args.default_address) + else: - metafile = MetaFile() - default_account = metafile.get_default_account() + default_account = mfile.get_default_account() default_string = lambda a: "*" if a.address == default_account else "" # noqa: E731 print("Accounts") print("========") diff --git a/solidbyte/cli/deploy.py b/solidbyte/cli/deploy.py index 1d03c54..b4ea79b 100644 --- a/solidbyte/cli/deploy.py +++ b/solidbyte/cli/deploy.py @@ -13,7 +13,7 @@ def add_parser_arguments(parser): """ Add additional subcommands onto this command """ parser.add_argument('network', metavar="NETWORK", type=str, nargs=1, help='Ethereum network to connect the console to') - parser.add_argument('-a', '--address', type=str, required=True, + parser.add_argument('-a', '--address', type=str, dest="address", help='Address of the Ethereum account to use for deployment') parser.add_argument( '-p', @@ -47,7 +47,7 @@ def main(parser_args): # Make sure we actually need to deploy if not deployer.check_needs_deploy(): log.info("No changes, deployment unnecessary") - sys.exit() + sys.exit(0) deployer.deploy() @@ -57,3 +57,5 @@ def main(parser_args): contract = deployer.contracts[name] log.info("{}: {}".format(contract.name, contract.address)) log.info("--------------------------------------") + + sys.exit(0) diff --git a/solidbyte/cli/script.py b/solidbyte/cli/script.py index fe46027..280ebe9 100644 --- a/solidbyte/cli/script.py +++ b/solidbyte/cli/script.py @@ -14,6 +14,8 @@ def add_parser_arguments(parser): help='Ethereum network to connect the console to') parser.add_argument('script', metavar="FILE", type=str, nargs='+', help='Script to run') + parser.add_argument('-a', '--address', type=str, dest="address", + help='Address of the Ethereum account used for deployment') return parser @@ -27,7 +29,7 @@ def main(parser_args): ', '.join(parser_args.script)) ) - res = run_scripts(collapse_oel(parser_args.network), parser_args.script) + res = run_scripts(collapse_oel(parser_args.network), parser_args.script, parser_args.address) if not res: log.error("Script{} returned error".format(scripts_plural)) diff --git a/solidbyte/cli/test.py b/solidbyte/cli/test.py index 70633e4..f33c29d 100644 --- a/solidbyte/cli/test.py +++ b/solidbyte/cli/test.py @@ -2,11 +2,12 @@ """ import sys from pathlib import Path +from enum import IntEnum from tabulate import tabulate from ..testing import run_tests from ..compile.artifacts import artifacts from ..common import collapse_oel -from ..common.exceptions import DeploymentValidationError +from ..common.exceptions import AccountError, DeploymentValidationError from ..common import store from ..common.web3 import web3c, remove_0x from ..common.logging import ConsoleStyle, getLogger @@ -21,6 +22,17 @@ GAS_ERROR = 47e5 # Ropsten is at 4.7m +class TestReturnCodes(IntEnum): + SUCCESS = 0 # Pytest "success" + TESTS_FAILED = 1 # pytest "some failed" + INTERRUPTED = 2 # pytest user interrupt + INTERNAL = 3 # pytest internal error + CLI_ERROR = 4 # pytest CLI usage error + NO_TESTS = 5 # pytest no tests found + ERROR = 10 # solidbyte error + NOT_ALLOWED = 11 # solidbyte not allowed + + def highlight_gas(gas): """ Uses console color highlights to indicate high gas usage """ @@ -98,14 +110,20 @@ def main(parser_args): return_code = run_tests(network_name, web3=web3, args=args, account_address=parser_args.address, keystore_dir=parser_args.keystore, gas_report_storage=report) + except AccountError as err: + if 'use_default_account' in str(err): + log.exception("Use of a default account dissallowed.") + sys.exit(TestReturnCodes.NOT_ALLOWED) + else: + raise err except DeploymentValidationError as err: if 'autodeployment' in str(err): - log.error("The -a/--address option or --default must be provided for autodeployment") - sys.exit(1) + log.error("The -a/--address option be provided for autodeployment") + sys.exit(TestReturnCodes.NOT_ALLOWED) else: raise err else: - if return_code != 0: + if return_code != TestReturnCodes.SUCCESS: log.error("Tests have failed. Return code: {}".format(return_code)) else: if parser_args.gas: diff --git a/solidbyte/common/metafile.py b/solidbyte/common/metafile.py index 4fc5395..dc81fd2 100644 --- a/solidbyte/common/metafile.py +++ b/solidbyte/common/metafile.py @@ -31,6 +31,7 @@ from typing import Union, Any, Optional, Callable, List, Tuple from pathlib import Path from datetime import datetime +from functools import wraps from shutil import copyfile from attrdict import AttrDict from .logging import getLogger @@ -48,6 +49,7 @@ def autoload(f: Callable) -> Callable: """ Automatically load the metafile before method execution """ + @wraps(f) def wrapper(*args, **kwargs): # A bit defensive, but make sure this is a decorator of a MetaFile method if len(args) > 0 and isinstance(args[0], MetaFile): @@ -58,6 +60,7 @@ def wrapper(*args, **kwargs): def autosave(f): """ Automatically save the metafile after method execution """ + @wraps(f) def wrapper(*args, **kwargs): retval = f(*args, **kwargs) # A bit defensive, but make sure this is a decorator of a MetaFile method diff --git a/solidbyte/deploy/__init__.py b/solidbyte/deploy/__init__.py index 42b539d..a6303c0 100644 --- a/solidbyte/deploy/__init__.py +++ b/solidbyte/deploy/__init__.py @@ -10,11 +10,11 @@ builddir, to_path_or_cwd, ) -from ..common.exceptions import DeploymentError +from ..common.exceptions import AccountError, DeploymentError from ..common.logging import getLogger from ..common.web3 import web3c from ..common.metafile import MetaFile -# from ..common.networks import NetworksYML +from ..common.networks import NetworksYML from .objects import Contract, ContractDependencyTree log = getLogger(__name__) @@ -78,10 +78,8 @@ def __init__(self, network_name: str, account: str = None, project_dir: PS = Non # else: self.metafile: MetaFile = MetaFile(project_dir=project_dir) - if account: - self.account = self.web3.toChecksumAddress(account) - else: - self.account = self.metafile.get_default_account() + self.account = None + self._init_account(account, fail_on_error=False) if not self.contracts_dir.is_dir(): raise FileNotFoundError("contracts directory does not exist") @@ -123,6 +121,9 @@ def get_contracts(self, force: bool = False): if force is False and len(self._contracts) > 0: return self._contracts + if not self.account: + self._init_account(fail_on_error=False) + self._contracts = AttrDict() for key in self.artifacts.keys(): self._contracts[key] = Contract( @@ -208,9 +209,13 @@ def deploy(self) -> bool: """ if not self.account: - raise DeploymentError("Account needs to be set for deployment") + self._init_account() + if not self.account: + raise DeploymentError("No account available.") + if self.account and self.web3.eth.getBalance(self.account) == 0: log.warning("Account has zero balance ({})".format(self.account)) + if self.network_id != (self.web3.net.chainId or self.web3.net.version): raise DeploymentError("Connected node is does not match the provided chain ID") @@ -219,6 +224,39 @@ def deploy(self) -> bool: return True + def _init_account(self, account=None, fail_on_error=True): + """ Try and figure out what account to use for deployment """ + + if account is not None: + account = self.web3.toChecksumAddress(account) + self.account = account + return + + if self.account is not None: + return + + yml = NetworksYML(project_dir=self.project_dir) + + if yml.use_default_account(self.network_name): + + self.account = self.metafile.get_default_account() + + if self.account is not None: + return self.account + elif fail_on_error: + raise DeploymentError( + "Account needs to be set for deployment. No default account found." + ) + + return None + + elif fail_on_error: + + raise AccountError( + "Use of default account on this network is not allowed and no account was" + "provided. You may want to set 'use_default_account: true' for this network." + ) + def _load_user_scripts(self) -> None: """ Load the user deploy scripts from deploy folder as python modules and stash 'em away for later execution. @@ -275,6 +313,12 @@ def _get_script_kwargs(self) -> Dict[str, T]: :returns: dict of the kwargs to provide to deployer scripts """ + + if not self.account: + self._init_account() + if not self.account: + raise DeploymentError("Account not set.") + return { 'contracts': self.contracts, 'web3': self.web3, diff --git a/solidbyte/script/__init__.py b/solidbyte/script/__init__.py index b841a2b..0118c2a 100644 --- a/solidbyte/script/__init__.py +++ b/solidbyte/script/__init__.py @@ -15,12 +15,12 @@ deployer: Optional[Deployer] = None -def get_contracts(network: str) -> AttrDict: +def get_contracts(network: str, account: str = None) -> AttrDict: """ Get a list of web3 contract instances. """ global deployer if not deployer: - deployer = Deployer(network_name=network) + deployer = Deployer(network_name=network, account=account) contracts: AttrDict = AttrDict({}) @@ -36,20 +36,20 @@ def get_contracts(network: str) -> AttrDict: return contracts -def get_availble_script_kwargs(network) -> Dict[str, Any]: +def get_availble_script_kwargs(network, account: str = None) -> Dict[str, Any]: """ Get a dict of the kwargs available for user scripts """ return { 'web3': web3c.get_web3(network), - 'contracts': get_contracts(network), + 'contracts': get_contracts(network, account), 'network': network, } -def run_script(network: str, script: str) -> bool: +def run_script(network: str, script: str, account: str = None) -> bool: """ Runs a user script """ scriptPath: Path = to_path(script) - availible_script_kwargs: Dict[str, Any] = get_availble_script_kwargs(network) + availible_script_kwargs: Dict[str, Any] = get_availble_script_kwargs(network, account) spec = spec_from_file_location(scriptPath.name[:-3], str(scriptPath)) mod: Optional[ModuleType] = module_from_spec(spec) @@ -74,11 +74,11 @@ def run_script(network: str, script: str) -> bool: return True -def run_scripts(network: str, scripts: List[str]) -> bool: +def run_scripts(network: str, scripts: List[str], account: str = None) -> bool: """ Run multiple user scripts """ if len(scripts) < 1: log.warning("No scripts provided") return True - return all([run_script(network, script) for script in scripts]) + return all([run_script(network, script, account) for script in scripts]) diff --git a/solidbyte/templates/templates/bare/networks.yml b/solidbyte/templates/templates/bare/networks.yml index 1889edb..ca981c1 100644 --- a/solidbyte/templates/templates/bare/networks.yml +++ b/solidbyte/templates/templates/bare/networks.yml @@ -6,6 +6,7 @@ dev: test: type: eth_tester autodeploy_allowed: true + use_default_account: true infura-mainnet: type: websocket diff --git a/solidbyte/templates/templates/erc20/networks.yml b/solidbyte/templates/templates/erc20/networks.yml index 1889edb..ca981c1 100644 --- a/solidbyte/templates/templates/erc20/networks.yml +++ b/solidbyte/templates/templates/erc20/networks.yml @@ -6,6 +6,7 @@ dev: test: type: eth_tester autodeploy_allowed: true + use_default_account: true infura-mainnet: type: websocket diff --git a/solidbyte/testing/__init__.py b/solidbyte/testing/__init__.py index 1346dc6..deac22f 100644 --- a/solidbyte/testing/__init__.py +++ b/solidbyte/testing/__init__.py @@ -1,6 +1,7 @@ import pytest from attrdict import AttrDict from ..accounts import Accounts +from ..compile import compile_all from ..deploy import Deployer, get_latest_from_deployed from ..common.utils import to_path, to_path_or_cwd from ..common.web3 import web3c @@ -89,6 +90,11 @@ def run_tests(network_name, args=[], web3=None, project_dir=None, account_addres log.debug("Using account {} for deployer.".format(account_address)) + log.info("Compiling contracts for testing...") + compile_all() + + log.info("Checking if deployment is necessary...") + # First, see if we're allowed to deploy, and whether we need to deployer = Deployer( network_name=network_name, @@ -103,6 +109,8 @@ def run_tests(network_name, args=[], web3=None, project_dir=None, account_addres if not account_address: raise DeploymentValidationError("Account needs to be provided for autodeployment") + log.info("Deploying contracts...") + deployer.deploy() elif deployer.check_needs_deploy() and not ( diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 670674f..7aadf71 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -28,6 +28,12 @@ def test_argparse_help(): ('accounts create', [ ('command', 'accounts'), ('account_command', 'create'), + ('create_default', False), + ]), + ('accounts create --default', [ + ('command', 'accounts'), + ('account_command', 'create'), + ('create_default', True), ]), ('accounts default -a {}'.format(ADDRESS_1), [ ('command', 'accounts'), @@ -39,11 +45,17 @@ def test_argparse_help(): ]), ('console test', [ ('command', 'console'), - ('network', ['test']), # TODO: Weirdness with argparse and nargs=1? + ('network', ['test']), + ]), + ('deploy test', [ + ('command', 'deploy'), + ('network', ['test']), + ('address', None), ]), ('deploy test -a {}'.format(ADDRESS_1), [ ('command', 'deploy'), - ('network', ['test']), # TODO: Weirdness with argparse and nargs=1? + ('network', ['test']), + ('address', ADDRESS_1), ]), ('init', [ ('command', 'init'), @@ -139,7 +151,6 @@ def test_argparse_valid(argv, expected): 'compile path/to/myfile.sol', # TODO: This maybe should be implemented 'console', 'deploy', - 'deploy test', # 'install', # 'install mypackage', # TODO: This maybe should be implemented 'show', diff --git a/tests/test_cli.py b/tests/test_cli.py index 1883485..20c39e4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,11 +37,14 @@ def execute_command_assert_no_error_success(cmd): list_proc.wait() if list_proc.returncode is None: list_proc.terminate() - if list_proc.returncode != 0: - print(list_output) - assert list_proc.returncode == 0, ( - "Invalid return code from command. Expected 0 but saw {}".format(list_proc.returncode) - ) + try: + assert list_proc.returncode == 0, ( + "Invalid return code from command. Expected 0 but saw {}".format(list_proc.returncode) + ) + except AssertionError as err: + print('--------------------------------------------------------') + print("Command failed: {}".format(' '.join(cmd))) + raise err return list_output @@ -119,16 +122,16 @@ def test_cli_integration(sb, mock_project, ganache): ]).decode('utf-8') # We're going to need the default account later - default_account = None - print("accounts_output: {}".format(accounts_output)) + first_account = None + print("accounts_output: \n{}".format(accounts_output)) for ln in accounts_output.split('\n'): # 0xC4cf518bDeDe4bdbE3d98f2F8E3195c7d9DC080B print("### matching {} against {}".format(ACCOUNT_MATCH_PATTERN, ln)) match = re.match(ACCOUNT_MATCH_PATTERN, ln) if match: - default_account = match.group(1) + first_account = match.group(1) break - assert default_account is not None, "Did not find an account to use" + assert first_account is not None, "Did not find an account to use" # test `sb accounts default -a [account]` execute_command_assert_no_error_success([ @@ -138,7 +141,7 @@ def test_cli_integration(sb, mock_project, ganache): 'accounts', 'default', '-a', - default_account + first_account, ]) # test `sb compile` @@ -149,7 +152,6 @@ def test_cli_integration(sb, mock_project, ganache): # execute_command_assert_no_error_success([*sb, 'console', 'test']) # test `sb deploy [network] -a [account]` - # Disabled. Need an account to test with execute_command_assert_no_error_success([ *sb, '-k', @@ -157,7 +159,7 @@ def test_cli_integration(sb, mock_project, ganache): 'deploy', gopts.network_name, '-a', - default_account, + first_account, '-p', PASSWORD_1, ]) @@ -165,7 +167,7 @@ def test_cli_integration(sb, mock_project, ganache): # test `sb show [network]` execute_command_assert_no_error_success([*sb, 'show', gopts.network_name]) - # test `sb test [network]` + # test `sb test -g [network]` execute_command_assert_no_error_success([ *sb, '-k', @@ -200,7 +202,7 @@ def test_cli_integration(sb, mock_project, ganache): 'test', 'nodeploy', '-a', - default_account, + first_account, '-p', PASSWORD_1, ], 'autodpeloy is not allowed') @@ -229,7 +231,7 @@ def test_cli_integration(sb, mock_project, ganache): str(TMP_KEY_DIR), 'deploy', '-a', - default_account, + first_account, '-p', PASSWORD_1, gopts.network_name, @@ -239,6 +241,8 @@ def test_cli_integration(sb, mock_project, ganache): execute_command_assert_no_error_success([ *sb, 'script', + '-a', + first_account, gopts.network_name, 'scripts/test_success.py', ]) @@ -249,6 +253,17 @@ def test_cli_integration(sb, mock_project, ganache): # test `sb sigs [contract]` execute_command_assert_no_error_success([*sb, 'sigs', 'Test']) + # test `sb test [network]` + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'test', + NETWORK_NAME, + '-p', + PASSWORD_1 + ]) + # Create a new project without the mock project_dir = mock.paths.project.joinpath('test-cli-init') project_dir.mkdir() @@ -261,6 +276,8 @@ def test_cli_integration(sb, mock_project, ganache): execute_command_assert_no_error_success([*sb, 'init', '-t', 'erc20']) execute_command_assert_no_error_success([*sb, 'compile']) + + # test `sb test [network] -a [account]` execute_command_assert_no_error_success([ *sb, '-k', @@ -268,7 +285,7 @@ def test_cli_integration(sb, mock_project, ganache): 'test', NETWORK_NAME, '-a', - default_account, + first_account, '-p', PASSWORD_1 ]) @@ -287,6 +304,118 @@ def test_cli_integration(sb, mock_project, ganache): os.chdir(orig_pwd) +@pytest.mark.parametrize("sb", [ + [SOLIDBYTE_COMMAND], + ['python', '-m', 'solidbyte'], +]) +def test_cli_stateless_deploy(sb, mock_project): + + with mock_project() as mock: + + TMP_KEY_DIR = mock.paths.project.joinpath('test-keys') + + # test `sb accounts create --default + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'accounts', + 'create', + '--default', + '-p', + PASSWORD_1, + ]) + + # test `sb deploy [network]` + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'deploy', + NETWORK_NAME, + '-p', + PASSWORD_1, + ]) + + +@pytest.mark.parametrize("sb", [ + [SOLIDBYTE_COMMAND], + ['python', '-m', 'solidbyte'], +]) +def test_cli_stateless_test(sb, mock_project): + + with mock_project() as mock: + + TMP_KEY_DIR = mock.paths.project.joinpath('test-keys') + + # test `sb accounts create --default + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'accounts', + 'create', + '--default', + '-p', + PASSWORD_1, + ]) + + # test `sb deploy [network]` + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'test', + NETWORK_NAME, + '-p', + PASSWORD_1, + ]) + + +@pytest.mark.parametrize("sb", [ + [SOLIDBYTE_COMMAND], + ['python', '-m', 'solidbyte'], +]) +def test_cli_stateless_erc20(sb, temp_dir): + + with temp_dir() as workdir: + + TMP_KEY_DIR = workdir.joinpath('test-keys') + + # test `sb accounts create --default + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'accounts', + 'create', + '--default', + '-p', + PASSWORD_1, + ]) + + # test `sb init -t [template]` + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'init', + '-t', + 'erc20' + ]) + + # test `sb test [network]` + execute_command_assert_no_error_success([ + *sb, + '-k', + str(TMP_KEY_DIR), + 'test', + NETWORK_NAME, + '-p', + PASSWORD_1, + ]) + + @pytest.mark.parametrize("sb", [ [SOLIDBYTE_COMMAND], ['python', '-m', 'solidbyte'], diff --git a/tests/test_metafile.py b/tests/test_metafile.py index 9fc81f6..b972825 100644 --- a/tests/test_metafile.py +++ b/tests/test_metafile.py @@ -40,6 +40,9 @@ def test_metafile(): assert mfile.set_default_account(ADDRESS_2) is None assert mfile.get_default_account() == normalize_address(ADDRESS_2) + mfile2 = MetaFile(filename_override=str(mfilename)) + assert mfile2.get_default_account() == mfile.get_default_account() + def test_metafile_cleanup(mock_project): """ Test the cleanup method of MetaFile. """