diff --git a/MANIFEST.in b/MANIFEST.in index 4ea58a1d..4cb3459a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ include README.md SIGNING_KEYS.pub LICENSE INSTALL include doc/wiki/using-mmgen/* include test/*.py +include test/include/*.py include test/test_py_d/*.py include test/objtest_py_d/*.py include test/objattrtest_py_d/*.py diff --git a/mmgen/altcoins/eth/contract.py b/mmgen/altcoins/eth/contract.py index aa204e7e..84752d16 100755 --- a/mmgen/altcoins/eth/contract.py +++ b/mmgen/altcoins/eth/contract.py @@ -67,7 +67,10 @@ class Token(MMGenObject): # ERC20 if g.debug: msg('ETH_CALL {}: {}'.format(method_sig,'\n '.join(parse_abi(data)))) ret = g.rpch.eth_call({ 'to': '0x'+self.addr, 'data': '0x'+data }) - return int(ret,16) * self.base_unit if toUnit else ret + if toUnit: + return int(ret,16) * self.base_unit + else: + return ret def balance(self,acct_addr): return ETHAmt(self.do_call('balanceOf(address)',acct_addr.rjust(64,'0'),toUnit=True)) @@ -98,11 +101,11 @@ class Token(MMGenObject): # ERC20 def info(self): fs = '{:15}{}\n' * 5 - return fs.format('token address:',self.addr, - 'token symbol:',self.symbol(), - 'token name:',self.name(), - 'decimals:',self.decimals(), - 'total supply:',self.total_supply()) + return fs.format('token address:', self.addr, + 'token symbol:', self.symbol(), + 'token name:', self.name(), + 'decimals:', self.decimals(), + 'total supply:', self.total_supply()) def code(self): return g.rpch.eth_getCode('0x'+self.addr)[2:] diff --git a/mmgen/altcoins/eth/tw.py b/mmgen/altcoins/eth/tw.py index 82a6d1f8..593e08a1 100755 --- a/mmgen/altcoins/eth/tw.py +++ b/mmgen/altcoins/eth/tw.py @@ -95,9 +95,9 @@ class EthereumTrackingWallet(TrackingWallet): r = self.data_root if addr in r: if not r[addr]['mmid'] and label.mmid: - msg("Warning: MMGen ID '{}' was missing in tracking wallet!".format(label.mmid)) + msg(f'Warning: MMGen ID {label.mmid!r} was missing in tracking wallet!') elif r[addr]['mmid'] != label.mmid: - die(3,"MMGen ID '{}' does not match tracking wallet!".format(label.mmid)) + die(3,'MMGen ID {label.mmid!r} does not match tracking wallet!') r[addr] = { 'mmid': label.mmid, 'comment': label.comment } @write_mode @@ -153,14 +153,15 @@ class EthereumTrackingWallet(TrackingWallet): if self.data['tokens'][addr]['params'].get('symbol') == sym.upper(): return addr - if no_rpc: return None + if no_rpc: + return None for addr in self.data['tokens']: if Token(addr).symbol().upper() == sym.upper(): self.force_set_token_param(addr,'symbol',sym.upper()) return addr - - return None + else: + return None def get_token_param(self,token,param): if token in self.data['tokens']: @@ -180,6 +181,7 @@ class EthereumTrackingWallet(TrackingWallet): class EthereumTokenTrackingWallet(EthereumTrackingWallet): + desc = 'Ethereum token tracking wallet' decimals = None symbol = None cur_eth_balances = {} @@ -315,7 +317,8 @@ class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs): prompt_fs = 'Total to spend: {} {}\n\n' col_adj = 37 - def get_display_precision(self): return 10 # truncate precision for narrow display + def get_display_precision(self): + return 10 # truncate precision for narrow display def get_unspent_data(self): super().get_unspent_data() @@ -336,11 +339,12 @@ class EthereumTwAddrList(TwAddrList): for mmid,d in list(tw_dict.items()): # if d['confirmations'] < minconf: continue # cannot get confirmations for eth account label = TwLabel(mmid+' '+d['comment'],on_fail='raise') - if usr_addr_list and (label.mmid not in usr_addr_list): continue + if usr_addr_list and (label.mmid not in usr_addr_list): + continue bal = self.wallet.get_balance(d['addr']) if bal == 0 and not showempty: - if not label.comment: continue - if not all_labels: continue + if not label.comment or not all_labels: + continue self[label.mmid] = {'amt': g.proto.coin_amt('0'), 'lbl': label } if showbtcaddrs: self[label.mmid]['addr'] = CoinAddr(d['addr']) diff --git a/mmgen/altcoins/eth/tx.py b/mmgen/altcoins/eth/tx.py index 0d660531..0b7b4a4b 100755 --- a/mmgen/altcoins/eth/tx.py +++ b/mmgen/altcoins/eth/tx.py @@ -118,7 +118,7 @@ class EthereumMMGenTX(MMGenTX): self.tx_gas = o['startGas'] # approximate, but better than nothing self.fee = self.fee_rel2abs(o['gasPrice'].toWei()) self.txobj = o - return d # 'token_addr','decimals' required by subclass + return d # 'token_addr','decimals' required by Token subclass def get_nonce(self): return ETHNonce(int(g.rpch.parity_nextNonce('0x'+self.inputs[0].addr),16)) @@ -149,7 +149,8 @@ class EthereumMMGenTX(MMGenTX): self.hex = json.dumps(odict) self.update_txid() - def del_output(self,idx): pass + def del_output(self,idx): + pass def update_txid(self): assert not is_hex_str(self.hex),'update_txid() must be called only when self.hex is not hex data' @@ -160,12 +161,14 @@ class EthereumMMGenTX(MMGenTX): def process_cmd_args(self,cmd_args,ad_f,ad_w): lc = len(cmd_args) - if lc == 0 and self.usr_contract_data and not 'Token' in type(self).__name__: return + if lc == 0 and self.usr_contract_data and not 'Token' in type(self).__name__: + return if lc != 1: fs = '{} output{} specified, but Ethereum transactions must have exactly one' die(1,fs.format(lc,suf(lc))) - for a in cmd_args: self.process_cmd_arg(a,ad_f,ad_w) + for a in cmd_args: + self.process_cmd_arg(a,ad_f,ad_w) def select_unspent(self,unspent): prompt = 'Enter an account to spend from: ' @@ -366,7 +369,8 @@ class EthereumMMGenTX(MMGenTX): self.get_status() - if prompt_user: self.confirm_send() + if prompt_user: + self.confirm_send() ret = None if g.bogus_send else g.rpch.eth_sendRawTransaction('0x'+self.hex,on_fail='return') @@ -374,11 +378,14 @@ class EthereumMMGenTX(MMGenTX): if rpc_error(ret): msg(yellow(rpc_errmsg(ret))) msg(red('Send of MMGen transaction {} failed'.format(self.txid))) - if exit_on_fail: sys.exit(1) + if exit_on_fail: + sys.exit(1) return False else: - m = 'BOGUS transaction NOT sent: {}' if g.bogus_send else 'Transaction sent: {}' - if not g.bogus_send: + if g.bogus_send: + m = 'BOGUS transaction NOT sent: {}' + else: + m = 'Transaction sent: {}' assert ret == '0x'+self.coin_txid,'txid mismatch (after sending)' self.desc = 'sent transaction' msg(m.format(self.coin_txid.hl())) diff --git a/mmgen/daemon.py b/mmgen/daemon.py index 565f42e8..1f13f3b5 100755 --- a/mmgen/daemon.py +++ b/mmgen/daemon.py @@ -56,6 +56,8 @@ class Daemon(MMGenObject): def exec_cmd(self,cmd,check): cp = run(cmd,check=False,stdout=PIPE,stderr=PIPE) + if self.debug: + print(cp) if check and cp.returncode != 0: raise MMGenCalledProcessError(cp) return cp @@ -172,8 +174,8 @@ class Daemon(MMGenObject): return True time.sleep(0.2) else: - m = 'Wait for state {!r} timeout exceeded for daemon {} {}' - die(2,m.format(req_state,self.daemon_id.upper(),self.network)) + m = 'Wait for state {!r} timeout exceeded for daemon {} {} (port {})' + die(2,m.format(req_state,self.daemon_id.upper(),self.network,self.rpc_port)) @classmethod def check_implement(cls): @@ -202,6 +204,13 @@ class Daemon(MMGenObject): die(1,'Flag {!r} not set, so cannot be removed'.format(val)) self._flags.remove(val) + def remove_datadir(self): + if self.state == 'stopped': + import shutil + shutil.rmtree(self.datadir,ignore_errors=True) + else: + msg(f'Cannot remove {self.datadir!r} - daemon is not stopped') + class MoneroWalletDaemon(Daemon): desc = 'RPC daemon' diff --git a/mmgen/devtools.py b/mmgen/devtools.py index a5f63ed4..922ae7e4 100755 --- a/mmgen/devtools.py +++ b/mmgen/devtools.py @@ -40,8 +40,11 @@ if os.getenv('MMGEN_DEBUG') or os.getenv('MMGEN_TEST_SUITE') or os.getenv('MMGEN class MMGenObject(object): # Pretty-print any object subclassed from MMGenObject, recursing into sub-objects - WIP - def pmsg(self): print(self.pfmt()) - def pdie(self): print(self.pfmt()); sys.exit(0) + def pmsg(self): + print(self.pfmt()) + def pdie(self): + print(self.pfmt()) + sys.exit(1) def pfmt(self,lvl=0,id_list=[]): scalars = (str,int,float,Decimal) def do_list(out,e,lvl=0,is_dict=False): diff --git a/mmgen/globalvars.py b/mmgen/globalvars.py index 8b28293e..65948e8a 100755 --- a/mmgen/globalvars.py +++ b/mmgen/globalvars.py @@ -37,6 +37,13 @@ class g(object): if s: sys.stderr.write(s+'\n') sys.exit(ev) + for k in ('linux','win','msys'): + if sys.platform[:len(k)] == k: + platform = { 'linux':'linux', 'win':'win', 'msys':'win' }[k] + break + else: + die(1,"'{}': platform not supported by {}\n".format(sys.platform,proj_name)) + # Constants: version = '0.12.099' @@ -89,7 +96,7 @@ class g(object): use_internal_keccak_module = False chain = None # set by first call to rpc_init() - chains = 'mainnet','testnet','regtest' + chains = ('mainnet','testnet','regtest') # rpc: rpc_host = '' @@ -118,13 +125,6 @@ class g(object): mnemonic_entry_modes = {} - for k in ('linux','win','msys'): - if sys.platform[:len(k)] == k: - platform = { 'linux':'linux', 'win':'win', 'msys':'win' }[k] - break - else: - die(1,"'{}': platform not supported by {}\n".format(sys.platform,proj_name)) - color = sys.stdout.isatty() if os.getenv('HOME'): # Linux or MSYS @@ -154,7 +154,8 @@ class g(object): # 'long' opts - opt sets global var common_opts = ( - 'color','no_license','rpc_host','rpc_port','testnet','rpc_user','rpc_password', + 'color','no_license','testnet', + 'rpc_host','rpc_port','rpc_user','rpc_password', 'monero_wallet_rpc_host','monero_wallet_rpc_user','monero_wallet_rpc_password', 'daemon_data_dir','force_256_color','regtest','coin','bob','alice', 'accept_defaults','token' diff --git a/mmgen/main_autosign.py b/mmgen/main_autosign.py index a99ff7dc..24748461 100755 --- a/mmgen/main_autosign.py +++ b/mmgen/main_autosign.py @@ -33,6 +33,7 @@ key_fn = 'autosign.key' from .common import * prog_name = os.path.basename(sys.argv[0]) opts_data = { + 'sets': [('stealth_led', True, 'led', True)], 'text': { 'desc': 'Auto-sign MMGen transactions', 'usage':'[opts] [command]', @@ -43,6 +44,7 @@ opts_data = { -I, --no-insert-check Don't check for device insertion -l, --led Use status LED to signal standby, busy and error -m, --mountpoint=m Specify an alternate mountpoint (default: '{mp}') +-n, --no-summary Don't print a transaction summary -s, --stealth-led Stealth LED mode - signal busy and error only, and only after successful authorization. -S, --full-summary Print a full summary of each signed transaction after @@ -116,9 +118,9 @@ from .protocol import CoinProtocol,init_coin if g.test_suite: from .daemon import CoinDaemon -if opt.stealth_led: opt.led = True +if opt.mountpoint: + mountpoint = opt.mountpoint # TODO: make global -if opt.mountpoint: mountpoint = opt.mountpoint # TODO: make global opt.outdir = tx_dir = os.path.join(mountpoint,'tx') def check_daemons_running(): @@ -132,47 +134,44 @@ def check_daemons_running(): for coin in coins: g.proto = CoinProtocol(coin,g.testnet) - if g.proto.sign_mode != 'daemon': - continue - if g.test_suite: - g.proto.daemon_data_dir = 'test/daemons/' + coin.lower() - g.rpc_port = CoinDaemon(get_network_id(coin,g.testnet),test_suite=True).rpc_port - vmsg('Checking {} daemon'.format(coin)) - try: - rpc_init(reinit=True) - g.rpch.getblockcount() - except SystemExit as e: - if e.code != 0: - fs = '{} daemon not running or not listening on port {}' - ydie(1,fs.format(coin,g.proto.rpc_port)) + if g.proto.sign_mode == 'daemon': + if g.test_suite: + g.proto.daemon_data_dir = 'test/daemons/' + coin.lower() + g.rpc_port = CoinDaemon(get_network_id(coin,g.testnet),test_suite=True).rpc_port + vmsg(f'Checking {coin} daemon') + try: + rpc_init(reinit=True) + except SystemExit as e: + if e.code != 0: + ydie(1,f'{coin} daemon not running or not listening on port {g.proto.rpc_port}') def get_wallet_files(): - m = "Cannot open wallet directory '{}'. Did you run 'mmgen-autosign setup'?" - try: dlist = os.listdir(wallet_dir) - except: die(1,m.format(wallet_dir)) + try: + dlist = os.listdir(wallet_dir) + except: + die(1,f"Cannot open wallet directory {wallet_dir!r}. Did you run 'mmgen-autosign setup'?") - wfs = [x for x in dlist if x[-6:] == '.mmdat'] - if not wfs: + fns = [x for x in dlist if x.endswith('.mmdat')] + if fns: + return [os.path.join(wallet_dir,w) for w in fns] + else: die(1,'No wallet files present!') - return [os.path.join(wallet_dir,w) for w in wfs] def do_mount(): if not os.path.ismount(mountpoint): if run(['mount',mountpoint],stderr=DEVNULL,stdout=DEVNULL).returncode == 0: - msg('Mounting '+mountpoint) + msg(f'Mounting {mountpoint}') try: ds = os.stat(tx_dir) - m1 = "'{}' is not a directory!" - m2 = "'{}' is not read/write for this user!" - assert S_ISDIR(ds.st_mode),m1.format(tx_dir) - assert ds.st_mode & S_IWUSR|S_IRUSR == S_IWUSR|S_IRUSR,m2.format(tx_dir) + assert S_ISDIR(ds.st_mode), f'{tx_dir!r} is not a directory!' + assert ds.st_mode & S_IWUSR|S_IRUSR == S_IWUSR|S_IRUSR,f'{tx_dir!r} is not read/write for this user!' except: - die(1,'{} missing, or not read/writable by user!'.format(tx_dir)) + die(1,'{tx_dir!r} missing, or not read/writable by user!') def do_umount(): if os.path.ismount(mountpoint): run(['sync'],check=True) - msg('Unmounting '+mountpoint) + msg(f'Unmounting {mountpoint}') run(['umount',mountpoint],check=True) def sign_tx_file(txfile,signed_txs): @@ -187,8 +186,8 @@ def sign_tx_file(txfile,signed_txs): init_coin(tmp_tx.coin,testnet=True) if hasattr(g.proto,'chain_name'): - m = 'Chains do not match! tx file: {}, proto: {}' - assert tmp_tx.chain == g.proto.chain_name,m.format(tmp_tx.chain,g.proto.chain_name) + if tmp_tx.chain != g.proto.chain_name: + die(2, f'Chains do not match! tx file: {tmp_tx.chain}, proto: {g.proto.chain_name}') g.chain = tmp_tx.chain g.token = tmp_tx.dcoin @@ -209,18 +208,17 @@ def sign_tx_file(txfile,signed_txs): else: return False except Exception as e: - msg('An error occurred: {}'.format(e.args[0])) + msg(f'An error occurred: {e.args[0]}') if g.debug or g.traceback: - print_stack_trace('AUTOSIGN {}'.format(txfile)) + print_stack_trace(f'AUTOSIGN {txfile}') return False except: return False def sign(): dirlist = os.listdir(tx_dir) - raw = [f for f in dirlist if f[-6:] == '.rawtx'] - signed = [f[:-6] for f in dirlist if f[-6:] == '.sigtx'] - unsigned = [os.path.join(tx_dir,f) for f in raw if f[:-6] not in signed] + raw,signed = [set(f[:-6] for f in dirlist if f.endswith(ext)) for ext in ('.rawtx','.sigtx')] + unsigned = [os.path.join(tx_dir,f+'.rawtx') for f in raw - signed] if unsigned: signed_txs,fails = [],[] @@ -233,10 +231,10 @@ def sign(): msg('{} transaction{} signed'.format(len(signed_txs),suf(signed_txs))) if fails: rmsg('{} transaction{} failed to sign'.format(len(fails),suf(fails))) - if signed_txs: + if signed_txs and not opt.no_summary: print_summary(signed_txs) if fails: - rmsg('{}Failed transactions:'.format('' if opt.full_summary else '\n')) + rmsg('\nFailed transactions:') rmsg(' ' + '\n '.join(sorted(fails)) + '\n') return False if fails else True else: @@ -248,7 +246,6 @@ def decrypt_wallets(): opt.hash_preset = '1' opt.set_by_user = ['hash_preset'] opt.passwd_file = os.path.join(tx_dir,key_fn) -# opt.passwd_file = '/tmp/key' from .wallet import Wallet msg("Unlocking wallet{} with key from '{}'".format(suf(wfs),opt.passwd_file)) fails = 0 @@ -261,43 +258,52 @@ def decrypt_wallets(): return False if fails else True - def print_summary(signed_txs): if opt.full_summary: bmsg('\nAutosign summary:\n') - for tx in signed_txs: - init_coin(tx.coin,tx.chain == 'testnet') - msg_r(tx.format_view(terse=True)) + def gen(): + for tx in signed_txs: + init_coin(tx.coin,tx.chain == 'testnet') + yield tx.format_view(terse=True) + msg_r(''.join(gen())) return - body = [] - for tx in signed_txs: - non_mmgen = [o for o in tx.outputs if not o.mmid] - if non_mmgen: - body.append((tx,non_mmgen)) + def gen(): + for tx in signed_txs: + non_mmgen = [o for o in tx.outputs if not o.mmid] + if non_mmgen: + yield (tx,non_mmgen) + + body = list(gen()) if body: bmsg('\nAutosign summary:') fs = '{} {} {}' t_wid,a_wid = 6,44 - msg(fs.format('TX ID ','Non-MMGen outputs'+' '*(a_wid-17),'Amount')) - msg(fs.format('-'*t_wid, '-'*a_wid, '-'*7)) - for tx,non_mmgen in body: - for nm in non_mmgen: - msg(fs.format( - tx.txid.fmt(width=t_wid,color=True) if nm is non_mmgen[0] else ' '*t_wid, - nm.addr.fmt(width=a_wid,color=True), - nm.amt.hl() + ' ' + yellow(tx.coin))) + + def gen(): + yield fs.format('TX ID ','Non-MMGen outputs'+' '*(a_wid-17),'Amount') + yield fs.format('-'*t_wid, '-'*a_wid, '-'*7) + for tx,non_mmgen in body: + for nm in non_mmgen: + yield fs.format( + tx.txid.fmt(width=t_wid,color=True) if nm is non_mmgen[0] else ' '*t_wid, + nm.addr.fmt(width=a_wid,color=True), + nm.amt.hl() + ' ' + yellow(tx.coin)) + + msg('\n'.join(gen())) else: msg('No non-MMGen outputs') def do_sign(): - if not opt.stealth_led: set_led('busy') + if not opt.stealth_led: + set_led('busy') do_mount() key_ok = decrypt_wallets() if key_ok: - if opt.stealth_led: set_led('busy') + if opt.stealth_led: + set_led('busy') ret = sign() do_umount() set_led(('standby','off','error')[(not ret)*2 or bool(opt.stealth_led)]) @@ -305,7 +311,8 @@ def do_sign(): else: msg('Password is incorrect!') do_umount() - if not opt.stealth_led: set_led('error') + if not opt.stealth_led: + set_led('error') return False def wipe_existing_key(): @@ -397,7 +404,8 @@ def set_led(cmd): led_thread.start() def get_insert_status(): - if opt.no_insert_check: return True + if opt.no_insert_check: + return True try: os.stat(os.path.join('/dev/disk/by-label',part_label)) except: return False else: return True @@ -471,11 +479,12 @@ if len(cmd_args) not in (0,1): opts.usage() if len(cmd_args) == 1: - if cmd_args[0] in ('gen_key','setup'): - globals()[cmd_args[0]]() + cmd = cmd_args[0] + if cmd in ('gen_key','setup'): + globals()[cmd]() sys.exit(0) - elif cmd_args[0] != 'wait': - die(1,"'{}': unrecognized command".format(cmd_args[0])) + elif cmd != 'wait': + die(1,f'{cmd!r}: unrecognized command') check_wipe_present() wfs = get_wallet_files() diff --git a/mmgen/main_tool.py b/mmgen/main_tool.py index 2d792774..d2599202 100755 --- a/mmgen/main_tool.py +++ b/mmgen/main_tool.py @@ -93,7 +93,9 @@ cmd_args = opts.init(opts_data,add_opts=['hidden_incog_input_params','in_fmt','u g.use_cached_balances = opt.cached_balances -if len(cmd_args) < 1: opts.usage() +if len(cmd_args) < 1: + opts.usage() + cmd = cmd_args.pop(0) import mmgen.tool as tool diff --git a/mmgen/main_txsend.py b/mmgen/main_txsend.py index d372115b..3d06bd74 100755 --- a/mmgen/main_txsend.py +++ b/mmgen/main_txsend.py @@ -44,9 +44,11 @@ rpc_init() if len(cmd_args) == 1: infile = cmd_args[0]; check_infile(infile) -else: opts.usage() +else: + opts.usage() -if not opt.status: do_license_msg() +if not opt.status: + do_license_msg() from .tx import * diff --git a/mmgen/main_txsign.py b/mmgen/main_txsign.py index 4062c0fa..b67b389e 100755 --- a/mmgen/main_txsign.py +++ b/mmgen/main_txsign.py @@ -91,8 +91,11 @@ column below: infiles = opts.init(opts_data,add_opts=['b16']) -if not infiles: opts.usage() -for i in infiles: check_infile(i) +if not infiles: + opts.usage() + +for i in infiles: + check_infile(i) if g.proto.sign_mode == 'daemon': rpc_init() diff --git a/mmgen/opts.py b/mmgen/opts.py index 88758797..c7db833f 100755 --- a/mmgen/opts.py +++ b/mmgen/opts.py @@ -112,7 +112,8 @@ def override_globals_from_cfg_file(ucfg): def override_globals_from_env(): for name in g.env_opts: - if name == 'MMGEN_DEBUG_ALL': continue + if name == 'MMGEN_DEBUG_ALL': + continue disable = name[:14] == 'MMGEN_DISABLE_' val = os.getenv(name) # os.getenv() returns None if env var is unset if val: # exclude empty string values; string value of '0' or 'false' sets variable to False @@ -528,9 +529,9 @@ def check_usr_opts(usr_opts): # Raises an exception if any check fails def check_and_set_autoset_opts(): # Raises exception if any check fails def nocase_str(key,val,asd): - if val.lower() in asd.choices: - return True - else: + try: + return asd.choices.index(val) + except: return 'one of' def nocase_pfx(key,val,asd): diff --git a/mmgen/protocol.py b/mmgen/protocol.py index 24b6ef9e..51c07728 100755 --- a/mmgen/protocol.py +++ b/mmgen/protocol.py @@ -67,6 +67,8 @@ def _b58chk_decode(s): raise ValueError('_b58chk_decode(): incorrect checksum') return out[:-4] +finfo = namedtuple('fork_info',['height','hash','name','replayable']) + # chainparams.cpp class BitcoinProtocol(MMGenObject): name = 'bitcoin' @@ -87,9 +89,9 @@ class BitcoinProtocol(MMGenObject): daemon_data_subdir = '' sighash_type = 'ALL' block0 = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f' - forks = [ # height, hash, name, replayable - (478559,'00000000000000000019f112ec0a9982926f1258cdcc558dd7c3b7e5dc7fa148','bch',False), - (None,'','b2x',True) + forks = [ + finfo(478559,'00000000000000000019f112ec0a9982926f1258cdcc558dd7c3b7e5dc7fa148','BCH',False), + finfo(None,'','B2X',True), ] caps = ('rbf','segwit') mmcaps = ('key','addr','rpc','tx') @@ -241,7 +243,7 @@ class BitcoinCashProtocol(BitcoinProtocol): mmtypes = ('L','C') sighash_type = 'ALL|FORKID' forks = [ - (478559,'000000000000000000651ef99cb9fcbe0dadde1d424bd9f15ff20136191a5eec','btc',False) + finfo(478559,'000000000000000000651ef99cb9fcbe0dadde1d424bd9f15ff20136191a5eec','BTC',False) ] caps = () coin_amt = BCHAmt @@ -267,7 +269,7 @@ class B2XProtocol(BitcoinProtocol): coin_amt = B2XAmt max_tx_fee = B2XAmt('0.1') forks = [ - (None,'','btc',True) # activation: 494784 + finfo(None,'','BTC',True) # activation: 494784 ] class B2XTestnetProtocol(B2XProtocol): diff --git a/mmgen/tool.py b/mmgen/tool.py index 5c927217..9b98c40c 100755 --- a/mmgen/tool.py +++ b/mmgen/tool.py @@ -264,9 +264,11 @@ class MMGenToolCmdMeta(type): def __contains__(cls,val): return cls.methods.__contains__(val) + def classname(cls,cmd_name): + return cls.methods[cmd_name].__qualname__.split('.')[0] + def call(cls,cmd_name,*args,**kwargs): - subcls = cls.classes[cls.methods[cmd_name].__qualname__.split('.')[0]] - return getattr(subcls(),cmd_name)(*args,**kwargs) + return getattr(cls.classes[cls.classname(cmd_name)](),cmd_name)(*args,**kwargs) @property def user_commands(cls): @@ -909,7 +911,10 @@ class MMGenToolCmdRPC(MMGenToolCmds): twuo.do_sort(sort,reverse=reverse) twuo.age_fmt = age_fmt twuo.show_mmid = show_mmid - ret = twuo.format_for_printing(color=True,show_confs=wide_show_confs) if wide else twuo.format_for_display() + if wide: + ret = twuo.format_for_printing(color=True,show_confs=wide_show_confs) + else: + ret = twuo.format_for_display() del twuo.wallet return ret @@ -940,7 +945,7 @@ class MMGenToolCmdMonero(MMGenToolCmds): Note that the use of these commands requires private data to be exposed on a network-connected machine in order to unlock the Monero wallets. This is - a violation of MMGen's security policy. + a violation of good security practice. """ _monero_chain_height = None @@ -999,7 +1004,7 @@ class MMGenToolCmdMonero(MMGenToolCmds): restore_height = blockheight, language = 'English' ) - pp_msg(ret) if opt.verbose else msg(' Address: {}'.format(ret['address'])) + pp_msg(ret) if opt.debug else msg(' Address: {}'.format(ret['address'])) return True def sync(n,d,fn,c,m): @@ -1043,7 +1048,7 @@ class MMGenToolCmdMonero(MMGenToolCmds): from .obj import XMRAmt bals[fn] = tuple([XMRAmt(ret[k],from_unit='min_coin_unit') for k in ('balance','unlocked_balance')]) - if opt.verbose: + if opt.debug: pp_msg(ret) else: msg(' Balance: {} Unlocked balance: {}'.format(*[b.hl() for b in bals[fn]])) diff --git a/mmgen/tw.py b/mmgen/tw.py index ad7520d1..7bd88938 100755 --- a/mmgen/tw.py +++ b/mmgen/tw.py @@ -161,7 +161,8 @@ watch-only wallet using '{}-addrimport' and then re-run this program. tr_rpc = [] lbl_id = ('account','label')['label_api' in g.rpch.caps] for o in us_rpc: - if not lbl_id in o: continue # coinbase outputs have no account field + if not lbl_id in o: + continue # coinbase outputs have no account field l = get_tw_label(o[lbl_id]) if l: o.update({ @@ -172,6 +173,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program. 'confs': o['confirmations'] }) tr_rpc.append(o) + self.unspent = self.MMGenTwOutputList( self.MMGenTwUnspentOutput( **{k:v for k,v in o.items() if k in dir(self.MMGenTwUnspentOutput)} @@ -668,14 +670,19 @@ class TrackingWallet(MMGenObject): del tw atexit.register(del_tw,self) - # TrackingWallet instances must be explicitly destroyed with 'del tw', 'del twuo.wallet' - # and the like to ensure the instance is deleted and wallet is written before global - # vars are destroyed by interpreter at shutdown. - # This is especially important, as exceptions are ignored within __del__(): - # /usr/share/doc/python3.6-doc/html/reference/datamodel.html#object.__del__ - # This code can only be debugged by examining the program output. Since no exceptions - # are raised, errors will not be caught by the test suite. def __del__(self): + """ + TrackingWallet instances opened in write or import mode must be explicitly destroyed + with 'del tw', 'del twuo.wallet' and the like to ensure the instance is deleted and + wallet is written before global vars are destroyed by the interpreter at shutdown. + + Not that this code can only be debugged by examining the program output, as exceptions + are ignored within __del__(): + + /usr/share/doc/python3.6-doc/html/reference/datamodel.html#object.__del__ + + Since no exceptions are raised, errors will not be caught by the test suite. + """ if g.debug: print_stack_trace('TW DEL {!r}'.format(self)) @@ -684,7 +691,8 @@ class TrackingWallet(MMGenObject): elif g.debug: msg('read-only wallet, doing nothing') - def upgrade_wallet_maybe(self): pass + def upgrade_wallet_maybe(self): + pass @staticmethod def conv_types(ad): @@ -825,12 +833,14 @@ class TrackingWallet(MMGenObject): from .addr import AddrData mmaddr = AddrData(source='tw').coinaddr2mmaddr(coinaddr) - if not mmaddr: mmaddr = '{}:{}'.format(g.proto.base_coin.lower(),coinaddr) + if not mmaddr: + mmaddr = '{}:{}'.format(g.proto.base_coin.lower(),coinaddr) mmaddr = TwMMGenID(mmaddr) cmt = TwComment(label,on_fail=on_fail) - if cmt in (False,None): return False + if cmt in (False,None): + return False lbl = TwLabel(mmaddr + ('',' '+cmt)[bool(cmt)],on_fail=on_fail) diff --git a/mmgen/tx.py b/mmgen/tx.py index bfda37a8..3ab0c230 100755 --- a/mmgen/tx.py +++ b/mmgen/tx.py @@ -313,7 +313,7 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam self.outputs = MMGenTxOutputList() self.send_amt = g.proto.coin_amt('0') # total amt minus change self.fee = g.proto.coin_amt('0') - self.hex = '' # raw serialized hex transaction + self.hex = '' # raw serialized hex transaction self.label = MMGenTXLabel('') self.txid = '' self.coin_txid = '' @@ -330,7 +330,8 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam if filename: self.parse_tx_file(filename,metadata_only=metadata_only,quiet_open=quiet_open) - if metadata_only: return + if metadata_only: + return self.check_pubkey_scripts() self.check_sigs() # marks the tx as signed @@ -538,8 +539,8 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam fee_per_kb = ret['feerate'] if 'feerate' in ret else -2 fe_type = 'estimatesmartfee' except: - fee_per_kb = g.rpch.estimatefee() if g.coin=='BCH' and g.rpch.daemon_version >= 190100 \ - else g.rpch.estimatefee(opt.tx_confs) + args = () if g.coin=='BCH' and g.rpc.daemon_version >= 190100 else (opt.tx_confs,) + fee_per_kb = await g.rpc.call('estimatefee',*args) fe_type = 'estimatefee' return fee_per_kb,fe_type @@ -982,7 +983,8 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam m = errmsg msg(yellow(m)) msg(red('Send of MMGen transaction {} failed'.format(self.txid))) - if exit_on_fail: sys.exit(1) + if exit_on_fail: + sys.exit(1) return False else: if g.bogus_send: @@ -1267,7 +1269,7 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam desc = 'send amount in metadata' self.send_amt = g.proto.coin_amt(send_amt,on_fail='raise') - desc = 'transaction hex data' + desc = 'transaction file hex data' self.check_txfile_hex_data() # the following ops will all fail if g.coin doesn't match self.coin desc = 'coin type in metadata' @@ -1457,7 +1459,8 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam self.twuo.display_total() - if do_info: sys.exit(0) + if do_info: + sys.exit(0) self.send_amt = self.sum_outputs() @@ -1515,7 +1518,7 @@ class MMGenBumpTX(MMGenTX): if not self.is_replaceable(): die(1,"Transaction '{}' is not replaceable".format(self.txid)) - # If sending, require tx to have been signed + # If sending, require tx to be signed if send: if not self.marked_signed(): die(1,"File '{}' is not a signed {} transaction file".format(filename,g.proj_name)) @@ -1568,7 +1571,8 @@ class MMGenBumpTX(MMGenTX): p = 'Fee will be deducted from output {}{} ({} {})'.format(idx+1,cs,o_amt,g.coin) if check_sufficient_funds(o_amt): if opt.yes or keypress_confirm(p+'. OK?',default_yes=True): - if opt.yes: msg(p) + if opt.yes: + msg(p) self.bump_output_idx = idx return idx diff --git a/mmgen/txsign.py b/mmgen/txsign.py index 9ea5134d..12ac7a37 100755 --- a/mmgen/txsign.py +++ b/mmgen/txsign.py @@ -150,7 +150,8 @@ def txsign(tx,seed_files,kl,kal,tx_num_str=''): tmp = KeyAddrList(addrlist=non_mm_addrs) tmp.add_wifs(kl) m = tmp.list_missing('sec') - if m: die(2,wmsg['missing_keys_error'].format(suf(m,'es'),'\n '.join(m))) + if m: + die(2,wmsg['missing_keys_error'].format(suf(m,'es'),'\n '.join(m))) keys += tmp.data if opt.mmgen_keys_from_file: diff --git a/mmgen/util.py b/mmgen/util.py index cfa6a56a..64f5f60d 100755 --- a/mmgen/util.py +++ b/mmgen/util.py @@ -96,9 +96,9 @@ def pp_fmt(d): def pp_msg(d): msg(pp_fmt(d)) -def fmt(s,indent=''): +def fmt(s,indent='',strip_char=None): "de-indent multiple lines of text, or indent with specified string" - return indent + ('\n'+indent).join([l.strip() for l in s.strip().splitlines()]) + '\n' + return indent + ('\n'+indent).join([l.strip(strip_char) for l in s.strip().splitlines()]) + '\n' def fmt_list(l,fmt='dfl',indent=''): "pretty-format a list" @@ -608,6 +608,8 @@ def write_data_to_file( outfile,data,desc='data', d = '' finally: if d != cmp_data: + if g.test_suite: + print_diff(cmp_data,d) m = "{} in file '{}' has been altered by some other program! Aborting file write" die(3,m.format(desc,outfile)) diff --git a/setup.py b/setup.py index 94f933c6..75cd42bc 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ import sys,os,subprocess from shutil import copy2 sys_ver = sys.version_info[:2] -req_ver = (3,7) +req_ver = (3,6) ver2f = lambda t: float('{}.{:03}'.format(*t)) if ver2f(sys_ver) < ver2f(req_ver): diff --git a/test/include/common.py b/test/include/common.py index adf1a5cf..0efd1822 100755 --- a/test/include/common.py +++ b/test/include/common.py @@ -165,25 +165,22 @@ def iqmsg_r(s): if not opt.quiet: omsg_r(s) def start_test_daemons(*network_ids): - if hasattr(opt,'no_daemon_autostart') and opt.no_daemon_autostart: - return - return test_daemons_ops(*network_ids,op='start') + if not opt.no_daemon_autostart: + return test_daemons_ops(*network_ids,op='start') def stop_test_daemons(*network_ids): - if hasattr(opt,'no_daemon_stop') and opt.no_daemon_stop: - return - return test_daemons_ops(*network_ids,op='stop') + if not opt.no_daemon_stop: + return test_daemons_ops(*network_ids,op='stop') def restart_test_daemons(*network_ids): stop_test_daemons(*network_ids) return start_test_daemons(*network_ids) def test_daemons_ops(*network_ids,op): - if opt.no_daemon_autostart: - return - from mmgen.daemon import CoinDaemon - silent = not opt.verbose and not (hasattr(opt,'exact_output') and opt.exact_output) - for network_id in network_ids: - if network_id not in CoinDaemon.network_ids: # silently ignore invalid IDs - continue - CoinDaemon(network_id,test_suite=True).cmd(op,silent=silent) + if not opt.no_daemon_autostart: + from mmgen.daemon import CoinDaemon + silent = not opt.verbose and not getattr(opt,'exact_output',False) + for network_id in network_ids: + if network_id.lower() not in CoinDaemon.network_ids: # silently ignore invalid IDs + continue + CoinDaemon(network_id,test_suite=True).cmd(op,silent=silent) diff --git a/test/test.py b/test/test.py index 86edb581..3d3b4cc3 100755 --- a/test/test.py +++ b/test/test.py @@ -60,8 +60,8 @@ def create_shm_dir(data_dir,trash_dir): dest = os.path.join(shm_dir,os.path.basename(trash_dir)) os.mkdir(dest,0o755) - try: os.unlink(trash_dir) - except: pass + + run(f'rm -rf {trash_dir}',shell=True,check=True) os.symlink(dest,trash_dir) dest = os.path.join(shm_dir,os.path.basename(data_dir)) @@ -115,6 +115,7 @@ opts_data = { -P, --profile Record the execution time of each script -q, --quiet Produce minimal output. Suppress dependency info -r, --resume=c Resume at command 'c' after interrupted run +-R, --resume-after=c Same, but resume at command following 'c' -s, --system Test scripts and modules installed on system rather than those in the repo root -S, --skip-deps Skip dependency checking for command @@ -168,13 +169,19 @@ if not ('resume' in _uopts or 'skip_deps' in _uopts): check_segwit_opts() -if opt.profile: opt.names = True -if opt.resume: opt.skip_deps = True +if opt.profile: + opt.names = True if opt.exact_output: def msg(s): pass qmsg = qmsg_r = vmsg = vmsg_r = msg_r = msg +if opt.resume or opt.resume_after: + opt.skip_deps = True + resume = opt.resume or opt.resume_after +else: + resume = False + cfgs = { # addr_idx_lists (except 31,32,33,34) must contain exactly 8 addresses '1': { 'wpasswd': 'Dorian-α', 'kapasswd': 'Grok the blockchain', @@ -514,7 +521,9 @@ class CmdGroupMgr(object): shared_deps are "implied" dependencies for all cmds in cmd_group that don't appear in the cmd_group data or cmds' argument lists. Supported only for 3seed tests at present. """ - if not hasattr(cls,'shared_deps'): return [] + if not hasattr(cls,'shared_deps'): + return [] + return [k for k,v in cfgs[str(tmpdir_idx)]['dep_generators'].items() if k in cls.shared_deps and v != cmdname] @@ -543,6 +552,7 @@ class CmdGroupMgr(object): def gm_init_group(self,trunner,gname,spawn_prog): kwargs = self.cmd_groups[gname][1] cls = self.create_group(gname,**kwargs) + cls.group_name = gname return cls(trunner,cfgs,spawn_prog) def list_cmd_groups(self): @@ -657,13 +667,15 @@ class TestSuiteRunner(object): else: omsg_r('Testing {}: '.format(desc)) - if msg_only: return + if msg_only: + return if opt.log: - try: - self.log_fd.write(cmd_disp+'\n') - except: - self.log_fd.write(ascii(cmd_disp)+'\n') + self.log_fd.write('[{}][{}:{}] {}\n'.format( + g.coin.lower(), + self.ts.group_name, + self.ts.test_name, + cmd_disp)) from test.include.pexpect import MMGenPexpect return MMGenPexpect(args,no_output=no_output) @@ -710,6 +722,11 @@ class TestSuiteRunner(object): self.ts = self.gm.gm_init_group(self,gname,self.spawn_wrapper) + if opt.resume_after: + global resume + resume = self.gm.cmd_list[self.gm.cmd_list.index(resume)+1] + omsg(f'INFO → Resuming at command {resume!r}') + if opt.exit_after and opt.exit_after not in self.gm.cmd_list: die(1,'{!r}: command not recognized'.format(opt.exit_after)) @@ -721,7 +738,8 @@ class TestSuiteRunner(object): if usr_args: for arg in usr_args: if arg in self.gm.cmd_groups: - if not self.init_group(arg): continue + if not self.init_group(arg): + continue clean(self.ts.tmpdir_nums) for cmd in self.gm.cmd_list: self.check_needs_rerun(cmd,build=True) @@ -753,8 +771,10 @@ class TestSuiteRunner(object): if e not in self.gm.cmd_groups_dfl: die(1,'{!r}: group not recognized'.format(e)) for gname in self.gm.cmd_groups_dfl: - if opt.exclude_groups and gname in exclude: continue - if not self.init_group(gname): continue + if opt.exclude_groups and gname in exclude: + continue + if not self.init_group(gname): + continue clean(self.ts.tmpdir_nums) for cmd in self.gm.cmd_list: self.check_needs_rerun(cmd,build=True) @@ -823,13 +843,13 @@ class TestSuiteRunner(object): if hasattr(self.ts,'shared_deps'): arg_list = arg_list[:-len(self.ts.shared_deps)] - if opt.resume: - if cmd == opt.resume: - bmsg('Resuming at {!r}'.format(cmd)) - opt.resume = False - opt.skip_deps = False - else: + global resume + if resume: + if cmd != resume: return + bmsg('Resuming at {!r}'.format(cmd)) + resume = False + opt.skip_deps = False if opt.profile: start = time.time() @@ -938,15 +958,18 @@ if opt.pause: set_restore_term_at_exit() set_environ_for_spawned_scripts() -start_test_daemons(network_id) +if network_id not in ('eth','etc'): + start_test_daemons(network_id) try: tr = TestSuiteRunner(data_dir,trash_dir) tr.run_tests(usr_args) tr.warn_skipped() - stop_test_daemons(network_id) + if network_id not in ('eth','etc'): + stop_test_daemons(network_id) except KeyboardInterrupt: - stop_test_daemons(network_id) + if network_id not in ('eth','etc'): + stop_test_daemons(network_id) tr.warn_skipped() die(1,'\ntest.py exiting at user request') except TestSuiteException as e: diff --git a/test/test_py_d/ts_autosign.py b/test/test_py_d/ts_autosign.py index e81407a0..42ee5150 100755 --- a/test/test_py_d/ts_autosign.py +++ b/test/test_py_d/ts_autosign.py @@ -157,12 +157,16 @@ class TestSuiteAutosign(TestSuiteBase): copy_files(mountpoint,remove_signed_only=True,include_bad_tx=not led_opts) do_unmount() do_loop() + imsg(purple('\nKilling wait loop!')) t.kill(2) # 2 = SIGINT t.req_exit_val = 1 return t def do_autosign(opts,mountpoint): - make_wallet(opts) + + if not opt.skip_deps: + make_wallet(opts) + copy_files(mountpoint,include_bad_tx=True) t = self.spawn('mmgen-autosign',opts+['--full-summary','wait'],extra_desc='(sign - full summary)') diff --git a/test/test_py_d/ts_ethdev.py b/test/test_py_d/ts_ethdev.py index 11f60eec..ab2da725 100755 --- a/test/test_py_d/ts_ethdev.py +++ b/test/test_py_d/ts_ethdev.py @@ -151,7 +151,7 @@ class TestSuiteEthdev(TestSuiteBase,TestSuiteShared): ('txcreate1', 'creating a transaction (spend from dev address to address :1)'), ('txsign1', 'signing the transaction'), - ('tx_status0', 'getting the transaction status'), + ('tx_status0_bad', 'getting the transaction status'), ('txsign1_ni', 'signing the transaction (non-interactive)'), ('txsend1', 'sending the transaction'), ('bal1', 'the {} balance'.format(g.coin)), @@ -425,7 +425,7 @@ class TestSuiteEthdev(TestSuiteBase,TestSuiteShared): return self.txcreate(args=args,menu=menu,acct='1',non_mmgen_inputs=1) def txsign1(self): return self.txsign(add_args=['--use-internal-keccak-module']) - def tx_status0(self): + def tx_status0_bad(self): return self.tx_status(ext='{}.sigtx',expect_str='neither in mempool nor blockchain',exit_val=1) def txsign1_ni(self): return self.txsign(ni=True) def txsend1(self): return self.txsend() diff --git a/test/test_py_d/ts_regtest.py b/test/test_py_d/ts_regtest.py index e9173012..f21d1753 100755 --- a/test/test_py_d/ts_regtest.py +++ b/test/test_py_d/ts_regtest.py @@ -725,11 +725,12 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared): return self.user_txdo('bob',rtFee[4],[pairs[0][1]],'3') def user_import(self,user,args): - t = self.spawn('mmgen-addrimport',['--quiet','--'+user]+args) + t = self.spawn('mmgen-addrimport',['--'+user]+args) if g.debug: t.expect("Type uppercase 'YES' to confirm: ",'YES\n') t.expect('Importing') t.expect('OK') + t.read() return t def bob_import_addr(self): diff --git a/test/tooltest.py b/test/tooltest.py index 399a7484..0391fb52 100755 --- a/test/tooltest.py +++ b/test/tooltest.py @@ -437,6 +437,7 @@ try: die(1,'Only one command may be specified') cmd = cmd_args[0] if cmd in cmd_data: + cleandir(cfg['tmpdir'],do_msg=True) msg('Running tests for {}:'.format(cmd_data[cmd]['desc'])) do_cmds(cmd) elif cmd == 'clean': diff --git a/test/unit_tests.py b/test/unit_tests.py index 7f3664d5..abcdd46f 100755 --- a/test/unit_tests.py +++ b/test/unit_tests.py @@ -28,7 +28,7 @@ from mmgen.common import * opts_data = { 'text': { 'desc': "Unit tests for the MMGen suite", - 'usage':'[options] [tests]', + 'usage':'[options] [tests | test [subtest]]', 'options': """ -h, --help Print this help message -A, --no-daemon-autostart Don't start and stop daemons automatically @@ -45,7 +45,7 @@ If no test is specified, all available tests are run } sys.argv.insert(1,'--skip-cfg-file') -cmd_args = opts.init(opts_data) +cmd_args = opts.init(opts_data,add_opts=['no_daemon_stop']) def exit_msg(): t = int(time.time()) - start_time @@ -81,20 +81,40 @@ class UnitTestHelpers(object): else: rdie(3,m_noraise.format(desc,exc_chk)) +def run_test(test,subtest=None): + modname = 'test.unit_tests_d.ut_{}'.format(test) + mod = importlib.import_module(modname) + + def run_subtest(subtest): + gmsg(f'Running unit subtest {test}.{subtest}') + t = getattr(mod,'unit_tests')() + if not getattr(t,subtest)(test,UnitTestHelpers): + rdie(1,f'Unit subtest {subtest!r} failed') + pass + + if subtest: + run_subtest(subtest) + else: + gmsg(f'Running unit test {test}') + if hasattr(mod,'unit_tests'): + t = getattr(mod,'unit_tests') + subtests = [k for k,v in t.__dict__.items() if type(v).__name__ == 'function'] + for subtest in subtests: + run_subtest(subtest) + else: + if not mod.unit_test().run_test(test,UnitTestHelpers): + rdie(1,'Unit test {test!r} failed') + try: - for test in cmd_args: - if test not in all_tests: - die(1,"'{}': test not recognized".format(test)) - import importlib - for test in (cmd_args or all_tests): - modname = 'test.unit_tests_d.ut_{}'.format(test) - mod = importlib.import_module(modname) - gmsg('Running unit test {}'.format(test)) - if not mod.unit_test().run_test(test,UnitTestHelpers): - rdie(1,'Unit test {!r} failed'.format(test)) - del mod - + if len(cmd_args) == 2 and cmd_args[0] in all_tests and cmd_args[1] not in all_tests: + run_test(*cmd_args) # assume 2nd arg is subtest + else: + for test in cmd_args: + if test not in all_tests: + die(1,f'{test!r}: test not recognized') + for test in (cmd_args or all_tests): + run_test(test) exit_msg() except KeyboardInterrupt: die(1,green('\nExiting at user request'))