Browse Source

minor changes and fixes throughout

The MMGen Project 4 years ago
parent
commit
57f12cd1cb

+ 1 - 0
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

+ 9 - 6
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:]

+ 13 - 9
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'])

+ 15 - 8
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()))

+ 11 - 2
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'

+ 5 - 2
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):

+ 10 - 9
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'

+ 72 - 63
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()

+ 3 - 1
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

+ 4 - 2
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 *
 

+ 5 - 2
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()

+ 5 - 4
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):

+ 7 - 5
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):

+ 11 - 6
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]]))

+ 21 - 11
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)
 

+ 13 - 9
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
 

+ 2 - 1
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:

+ 4 - 2
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))
 

+ 1 - 1
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):

+ 11 - 14
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)

+ 45 - 22
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:

+ 5 - 1
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)')

+ 2 - 2
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()

+ 2 - 1
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):

+ 1 - 0
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':

+ 34 - 14
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))
 
-try:
-	for test in cmd_args:
-		if test not in all_tests:
-			die(1,"'{}': test not recognized".format(test))
+def run_test(test,subtest=None):
+	modname = 'test.unit_tests_d.ut_{}'.format(test)
+	mod = importlib.import_module(modname)
 
-	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
+	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:
+	import importlib
+	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'))