From d07d665f3530af7a4fa9840442e10faede01a7a6 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Mon, 15 Aug 2022 12:38:46 +0000 Subject: [PATCH] `mmgen-tool`: improve usage screens for individual commands Example: $ mmgen-tool help listaddresses Testing/demo: $ test/test.py -e tool_cmd_usage --- mmgen/data/version | 2 +- mmgen/main_tool.py | 23 ++++++++--- mmgen/tool/file.py | 6 ++- mmgen/tool/fileutil.py | 23 ++++++++++- mmgen/tool/help.py | 67 ++++++++++++++++++++++-------- mmgen/tool/mnemonic.py | 12 ++++-- mmgen/tool/rpc.py | 61 ++++++++++++++------------- mmgen/tool/util.py | 87 ++++++++++++++++++++++++++++++++------- test/test_py_d/ts_misc.py | 14 +++++++ 9 files changed, 220 insertions(+), 75 deletions(-) diff --git a/mmgen/data/version b/mmgen/data/version index a0f75a00..d7a69cc1 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -13.2.dev12 +13.2.dev13 diff --git a/mmgen/main_tool.py b/mmgen/main_tool.py index 654f2d2e..38ecbaaf 100755 --- a/mmgen/main_tool.py +++ b/mmgen/main_tool.py @@ -36,6 +36,7 @@ opts_data = { -k, --use-internal-keccak-module Force use of the internal keccak module -K, --keygen-backend=n Use backend 'n' for public key generation. Options for {coin_id}: {kgs} +-l, --list List available commands -p, --hash-preset= p Use the scrypt hash parameters defined by preset 'p' for password hashing (default: '{g.dfl_hash_preset}') -P, --passwd-file= f Get passphrase from file 'f'. @@ -194,11 +195,10 @@ def create_call_sig(cmd,cls,as_string=False): get_type_from_ann = lambda x: 'str or STDIN' if ann[x] == 'sstr' else ann[x].__name__ return ' '.join( [f'{a} [{get_type_from_ann(a)}]' for a in args[:nargs]] + - ['{a} [{b}={c!r}{d}]'.format( + ['{a} [{b}={c!r}]'.format( a = a, b = dfl_types[n].__name__, - c = dfls[n], - d = (' ' + ann[a] if a in ann and isinstance(ann[a],str) else '')) + c = dfls[n] ) for n,a in enumerate(args[nargs:])] ) else: get_type_from_ann = lambda x: 'str' if ann[x] == 'sstr' else ann[x].__name__ @@ -206,10 +206,11 @@ def create_call_sig(cmd,cls,as_string=False): [(a,get_type_from_ann(a)) for a in args[:nargs]], # c_args dict([(a,dfls[n]) for n,a in enumerate(args[nargs:])]), # c_kwargs dict([(a,dfl_types[n]) for n,a in enumerate(args[nargs:])]), # c_kwargs_types - ('STDIN_OK' if nargs and ann[args[0]] == 'sstr' else flag) ) # flag + ('STDIN_OK' if nargs and ann[args[0]] == 'sstr' else flag), # flag + ann ) # ann def process_args(cmd,cmd_args,cls): - c_args,c_kwargs,c_kwargs_types,flag = create_call_sig(cmd,cls) + c_args,c_kwargs,c_kwargs_types,flag,ann = create_call_sig(cmd,cls) have_stdin_input = False def usage_die(s): @@ -339,6 +340,18 @@ if g.prog_name == 'mmgen-tool' and not opt._lock: po = opts.init( opts_data, parse_only=True ) + if po.user_opts.get('list'): + def gen(): + for mod,cmdlist in mods.items(): + if mod == 'help': + continue + yield capfirst( get_mod_cls(mod).__doc__.lstrip().split('\n')[0] ) + ':' + for cmd in cmdlist: + yield ' ' + cmd + yield '' + Msg('\n'.join(gen()).rstrip()) + sys.exit(0) + if len(po.cmd_args) < 1: opts.usage() diff --git a/mmgen/tool/file.py b/mmgen/tool/file.py index 63ae947f..b61d49ef 100755 --- a/mmgen/tool/file.py +++ b/mmgen/tool/file.py @@ -78,8 +78,10 @@ class tool_cmd(tool_cmd_base): 'dfls': ( False, False, 'addr', 'mtime' ), 'annots': { 'mmgen_tx_file(s)': str, - 'sort': options_annot_str(['addr','raw']), - 'filesort': options_annot_str(['mtime','ctime','atime']), + 'pager': 'send output to pager', + 'terse': 'produce compact tabular output', + 'sort': 'sort order for transaction inputs and outputs ' + options_annot_str(['addr','raw']), + 'filesort': 'file sort order ' + options_annot_str(['mtime','ctime','atime']), } }, *infiles, diff --git a/mmgen/tool/fileutil.py b/mmgen/tool/fileutil.py index bae1908e..1ddc4ac6 100755 --- a/mmgen/tool/fileutil.py +++ b/mmgen/tool/fileutil.py @@ -29,7 +29,10 @@ from ..crypto import get_random,aesctr_iv_len class tool_cmd(tool_cmd_base): "file utilities" - def find_incog_data(self,filename:str,incog_id:str,keep_searching=False): + def find_incog_data(self, + filename: str, + incog_id: str, + keep_searching: 'continue search after finding data (ID collisions can yield false positives)' = False): "Use an Incog ID to find hidden incognito wallet data" from hashlib import sha256 @@ -67,6 +70,24 @@ class tool_cmd(tool_cmd_base): def rand2file(self,outfile:str,nbytes:str,threads=4,silent=False): """ write ‘nbytes’ bytes of random data to specified file (dd-style byte specifiers supported) + + Valid specifiers: + + c = 1 + w = 2 + b = 512 + kB = 1000 + K = 1024 + MB = 1000000 + M = 1048576 + GB = 1000000000 + G = 1073741824 + TB = 1000000000000 + T = 1099511627776 + PB = 1000000000000000 + P = 1125899906842624 + EB = 1000000000000000000 + E = 1152921504606846976 """ from threading import Thread from queue import Queue diff --git a/mmgen/tool/help.py b/mmgen/tool/help.py index a793765f..7ceef59a 100755 --- a/mmgen/tool/help.py +++ b/mmgen/tool/help.py @@ -25,23 +25,20 @@ import mmgen.main_tool as main_tool def main_help(): - from ..util import pretty_format + from ..util import pretty_format,capfirst def do(): for clsname,cmdlist in main_tool.mods.items(): cls = main_tool.get_mod_cls(clsname) - cls_doc = cls.__doc__.strip().split('\n') - for l in cls_doc: - if l is cls_doc[0]: - l += ':' - l = l.replace('\t','',1) - if l: - l = l.replace('\t',' ') - yield l[0].upper() + l[1:] - else: - yield '' + cls_docstr = cls.__doc__.strip() + yield capfirst(cls_docstr.split('\n')[0].strip()) + ':' yield '' + if '\n' in cls_docstr: + for line in cls_docstr.split('\n')[2:]: + yield ' ' + line.lstrip('\t') + yield '' + max_w = max(map(len,cmdlist)) for cmdname in cmdlist: @@ -64,7 +61,7 @@ def gen_tool_usage(): from ..util import capfirst m1 = """ - USAGE INFORMATION FOR MMGEN-TOOL COMMANDS: + GENERAL USAGE INFORMATION FOR MMGEN-TOOL COMMANDS Arguments with only type specified in square brackets are required @@ -140,15 +137,49 @@ def gen_tool_cmd_usage(mod,cmdname): cls = main_tool.get_mod_cls(mod) docstr = getattr(cls,cmdname).__doc__.strip() - args,kwargs,kwargs_types,flag = main_tool.create_call_sig(cmdname,cls) + args,kwargs,kwargs_types,flag,ann = main_tool.create_call_sig(cmdname,cls) + ARGS = 'ARG' if len(args) == 1 else 'ARGS' if args else '' + KWARGS = 'KEYWORD ARG' if len(kwargs) == 1 else 'KEYWORD ARGS' if kwargs else '' - yield '{a}\n\nUSAGE: {b} {c} {d}{e}'.format( - a = capfirst( docstr.split('\n')[0].strip() ), + yield capfirst( docstr.split('\n')[0].strip() ) + yield '' + yield 'USAGE: {b} [OPTS] {c}{d}{e}'.format( b = g.prog_name, c = cmdname, - d = main_tool.create_call_sig(cmdname,cls,as_string=True), - e = '\n\n' + fmt('\n'.join(docstr.split('\n')[1:]),strip_char='\t').rstrip() - if '\n' in docstr else '' ) + d = f' {ARGS}' if ARGS else '', + e = f' [{KWARGS}]' if KWARGS else '' ) + + if args: + max_w = max(len(k[0]) for k in args) + yield '' + yield f'Required {ARGS} (type shown in square brackets):' + yield '' + for argname,argtype in args: + have_sstr = ann.get(argname) == 'sstr' + yield ' {a:{w}} [{b}]{c}{d}'.format( + a = argname, + b = argtype, + c = " (use '-' to read from STDIN)" if have_sstr else '', + d = ' ' + ann[argname] if isinstance(ann.get(argname),str) and not have_sstr else '', + w = max_w ) + + if kwargs: + max_w = max(len(k) for k in kwargs) + max_w2 = max(len(kwargs_types[k].__name__) + len(repr(kwargs[k])) for k in kwargs) + 3 + yield '' + yield f'Optional {KWARGS} (type and default value shown in square brackets):' + yield '' + for argname in kwargs: + yield ' {a:{w}} {b:{w2}} {c}'.format( + a = argname, + b = '[{}={}]'.format( kwargs_types[argname].__name__, repr(kwargs[argname]) ), + c = capfirst(ann[argname]) if isinstance(ann.get(argname),str) else '', + w = max_w, + w2 = max_w2 ).rstrip() + + if '\n' in docstr: + for line in docstr.split('\n')[1:]: + yield line.lstrip('\t') def usage(cmdname=None,exit_val=1): diff --git a/mmgen/tool/mnemonic.py b/mmgen/tool/mnemonic.py index 012e9dc4..5ee8d7fd 100755 --- a/mmgen/tool/mnemonic.py +++ b/mmgen/tool/mnemonic.py @@ -35,7 +35,7 @@ mnemonic_fmts = { 'bip39': mft( 'bip39', None, bip39 ), 'xmrseed': mft( 'xmrseed', None, xmrseed ), } -mn_opts_disp = options_annot_str(mnemonic_fmts) +mn_opts_disp = 'seed phrase format ' + options_annot_str(mnemonic_fmts) class tool_cmd(tool_cmd_base): """ @@ -106,7 +106,10 @@ class tool_cmd(tool_cmd_base): f = mnemonic_fmts[fmt] return f.conv_cls(fmt).tohex( seed_mnemonic.split(), f.pad ) - def mn2hex_interactive( self, fmt:mn_opts_disp = dfl_mnemonic_fmt, mn_len=24, print_mn=False ): + def mn2hex_interactive( self, + fmt: mn_opts_disp = dfl_mnemonic_fmt, + mn_len: 'length of seed phrase in words' = 24, + print_mn: 'print the seed phrase after entry' = False ): "convert an interactively supplied mnemonic seed phrase to a hexadecimal string" from ..mn_entry import mn_entry mn = mn_entry(fmt).get_mnemonic_from_user(25 if fmt == 'xmrseed' else mn_len,validate=False) @@ -119,7 +122,10 @@ class tool_cmd(tool_cmd_base): "show stats for a mnemonic wordlist" return mnemonic_fmts[fmt].conv_cls(fmt).check_wordlist() - def mn_printlist( self, fmt:mn_opts_disp = dfl_mnemonic_fmt, enum=False, pager=False ): + def mn_printlist(self, + fmt: mn_opts_disp = dfl_mnemonic_fmt, + enum: 'enumerate the list' = False, + pager: 'send output to pager' = False ): "print a mnemonic wordlist" ret = mnemonic_fmts[fmt].conv_cls(fmt).get_wordlist() if enum: diff --git a/mmgen/tool/rpc.py b/mmgen/tool/rpc.py index c34b333d..ea8e2c85 100755 --- a/mmgen/tool/rpc.py +++ b/mmgen/tool/rpc.py @@ -22,6 +22,7 @@ tool/rpc.py: JSON/RPC routines for the 'mmgen-tool' utility from .common import tool_cmd_base,options_annot_str from ..tw.common import TwCommon +from ..tw.txhistory import TwTxHistory class tool_cmd(tool_cmd_base): "tracking-wallet commands using the JSON-RPC interface" @@ -35,16 +36,19 @@ class tool_cmd(tool_cmd_base): r = await rpc_init( self.proto, ignore_daemon_version=True ) return f'{r.daemon.coind_name} version {r.daemon_version} ({r.daemon_version_str})' - async def getbalance(self,minconf=1,quiet=False,pager=False): + async def getbalance(self, + minconf: 'minimum number of confirmations' = 1, + quiet: 'produce quieter output' = False, + pager: 'send output to pager' = False ): "list confirmed/unconfirmed, spendable/unspendable balances in tracking wallet" from ..tw.bal import TwGetBalance return (await TwGetBalance(self.proto,minconf,quiet)).format() async def listaddress(self, mmgen_addr:str, - minconf = 1, - showbtcaddr = True, - age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs' ): + minconf: 'minimum number of confirmations' = 1, + showbtcaddr: 'display coin address in addition to MMGen ID' = True, + age_fmt: 'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs' ): "list the specified MMGen address in the tracking wallet and its balance" return await self.listaddresses( @@ -54,15 +58,15 @@ class tool_cmd(tool_cmd_base): age_fmt = age_fmt ) async def listaddresses(self, - mmgen_addrs:'(range or list)' = '', - minconf = 1, - showempty = False, - pager = False, - showbtcaddrs = True, - all_labels = False, - sort: options_annot_str(['reverse','age']) = '', - age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs' ): - "list MMGen addresses and their balances" + mmgen_addrs: 'hyphenated range or comma-separated list of addresses' = '', + minconf: 'minimum number of confirmations' = 1, + pager: 'send output to pager' = False, + showbtcaddr: 'display coin addresses in addition to MMGen IDs' = True, + showempty: 'show addresses with no balances' = True, + all_labels: 'show all addresses with labels' = False, + age_fmt: 'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs', + sort: 'address sort order ' + options_annot_str(['reverse','age']) = '' ): + "list MMGen addresses in the tracking wallet and their balances" show_age = bool(age_fmt) @@ -111,14 +115,14 @@ class tool_cmd(tool_cmd_base): return await obj.format_squeezed() async def twview(self, - pager = False, - reverse = False, - wide = False, - minconf = 1, - sort = 'age', - age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs', - interactive = False, - show_mmid = True ): + pager: 'send output to pager' = False, + reverse: 'reverse order of unspent outputs' = False, + wide: 'display data in wide tabular format' = False, + minconf: 'minimum number of confirmations' = 1, + sort: 'unspent output sort order ' + options_annot_str(TwCommon.sort_funcs) = 'age', + age_fmt: 'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs', + interactive: 'enable interactive operation' = False, + show_mmid: 'show MMGen IDs along with coin addresses' = True ): "view tracking wallet unspent outputs" from ..tw.unspent import TwUnspentOutputs @@ -129,16 +133,15 @@ class tool_cmd(tool_cmd_base): return ret async def txhist(self, - pager = False, - reverse = False, - detail = False, - sinceblock = 0, - sort = 'age', - age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs', - interactive = False ): + pager: 'send output to pager' = False, + reverse: 'reverse order of transactions' = False, + detail: 'produce detailed, non-tabular output' = False, + sinceblock: 'display transactions starting from this block' = 0, + sort: 'transaction sort order ' + options_annot_str(TwTxHistory.sort_funcs) = 'age', + age_fmt: 'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs', + interactive: 'enable interactive operation' = False ): "view transaction history of tracking wallet" - from ..tw.txhistory import TwTxHistory obj = await TwTxHistory(self.proto,sinceblock=sinceblock) return await self.twops( obj,pager,reverse,detail,sort,age_fmt,interactive,show_mmid=None) diff --git a/mmgen/tool/util.py b/mmgen/tool/util.py index a8e38035..05811649 100755 --- a/mmgen/tool/util.py +++ b/mmgen/tool/util.py @@ -25,21 +25,64 @@ from .common import tool_cmd_base class tool_cmd(tool_cmd_base): "general string conversion and hashing utilities" + # mmgen.util.bytespec_map def bytespec(self,dd_style_byte_specifier:str): - "convert a byte specifier such as '1GB' into an integer" + """ + convert a byte specifier such as ‘4GB’ into an integer + + Valid specifiers: + + c = 1 + w = 2 + b = 512 + kB = 1000 + K = 1024 + MB = 1000000 + M = 1048576 + GB = 1000000000 + G = 1073741824 + TB = 1000000000000 + T = 1099511627776 + PB = 1000000000000000 + P = 1125899906842624 + EB = 1000000000000000000 + E = 1152921504606846976 + """ from ..util import parse_bytespec return parse_bytespec(dd_style_byte_specifier) + # mmgen.util.bytespec_map def to_bytespec(self, n: int, dd_style_byte_specifier: str, - fmt = '0.2', - print_sym = True ): - "convert an integer to a byte specifier such as '1GB'" + fmt: 'width and precision of output' = '0.2', + print_sym: 'print the specifier after the numerical value' = True ): + """ + convert an integer to a byte specifier such as ‘4GB’ + + Supported specifiers: + + c = 1 + w = 2 + b = 512 + kB = 1000 + K = 1024 + MB = 1000000 + M = 1048576 + GB = 1000000000 + G = 1073741824 + TB = 1000000000000 + T = 1099511627776 + PB = 1000000000000000 + P = 1125899906842624 + EB = 1000000000000000000 + E = 1152921504606846976 + """ from ..util import int2bytespec return int2bytespec( n, dd_style_byte_specifier, fmt, print_sym ) - def randhex(self,nbytes=32): + def randhex(self, + nbytes: 'number of bytes to output' = 32 ): "print 'n' bytes (default 32) of random data in hex format" from ..crypto import get_random return get_random( nbytes ).hex() @@ -58,7 +101,10 @@ class tool_cmd(tool_cmd_base): "convert a hexadecimal string to bytes (warning: outputs binary data)" return bytes.fromhex(hexstr) - def hexdump(self,infile:str,cols=8,line_nums='hex'): + def hexdump(self, + infile: str, + cols: 'number of columns in output' = 8, + line_nums: "format for line numbers (valid choices: 'hex','dec')" = 'hex'): "create hexdump of data from file (use '-' for stdin)" from ..fileutil import get_data_from_file from ..util import pretty_hexdump @@ -81,7 +127,11 @@ class tool_cmd(tool_cmd_base): from ..proto.common import hash160 return hash160( bytes.fromhex(hexstr) ).hex() - def hash256(self,data:str,file_input=False,hex_input=False): # TODO: handle stdin + # TODO: handle stdin + def hash256(self, + data: str, + file_input: 'first arg is the name of a file containing the data' = False, + hex_input: 'first arg is a hexadecimal string' = False ): "compute sha256(sha256(data)) (double sha256)" from hashlib import sha256 if file_input: @@ -113,30 +163,32 @@ class tool_cmd(tool_cmd_base): return make_chksum_8( get_data_from_file( infile, dash=True, quiet=True, binary=True )) - def randb58(self,nbytes=32,pad=0): + def randb58(self, + nbytes: 'number of bytes to output' = 32, + pad: 'pad output to this width' = 0 ): "generate random data (default: 32 bytes) and convert it to base 58" from ..crypto import get_random from ..baseconv import baseconv return baseconv('b58').frombytes( get_random(nbytes), pad=pad, tostr=True ) - def bytestob58(self,infile:str,pad=0): + def bytestob58(self,infile:str,pad: 'pad output to this width' = 0): "convert bytes to base 58 (supply data via STDIN)" from ..fileutil import get_data_from_file from ..baseconv import baseconv data = get_data_from_file( infile, dash=True, quiet=True, binary=True ) return baseconv('b58').frombytes( data, pad=pad, tostr=True ) - def b58tobytes(self,b58_str:'sstr',pad=0): + def b58tobytes(self,b58_str:'sstr',pad: 'pad output to this width' = 0): "convert a base 58 string to bytes (warning: outputs binary data)" from ..baseconv import baseconv return baseconv('b58').tobytes( b58_str, pad=pad ) - def hextob58(self,hexstr:'sstr',pad=0): + def hextob58(self,hexstr:'sstr',pad: 'pad output to this width' = 0): "convert a hexadecimal string to base 58" from ..baseconv import baseconv return baseconv('b58').fromhex( hexstr, pad=pad, tostr=True ) - def b58tohex(self,b58_str:'sstr',pad=0): + def b58tohex(self,b58_str:'sstr',pad: 'pad output to this width' = 0): "convert a base 58 string to hexadecimal" from ..baseconv import baseconv return baseconv('b58').tohex( b58_str, pad=pad ) @@ -151,24 +203,27 @@ class tool_cmd(tool_cmd_base): from ..proto.common import b58chk_decode return b58chk_decode(b58chk_str).hex() - def hextob32(self,hexstr:'sstr',pad=0): + def hextob32(self,hexstr:'sstr',pad: 'pad output to this width' = 0): "convert a hexadecimal string to an MMGen-flavor base 32 string" from ..baseconv import baseconv return baseconv('b32').fromhex( hexstr, pad, tostr=True ) - def b32tohex(self,b32_str:'sstr',pad=0): + def b32tohex(self,b32_str:'sstr',pad: 'pad output to this width' = 0): "convert an MMGen-flavor base 32 string to hexadecimal" from ..baseconv import baseconv return baseconv('b32').tohex( b32_str.upper(), pad ) - def hextob6d(self,hexstr:'sstr',pad=0,add_spaces=True): + def hextob6d(self, + hexstr:'sstr', + pad: 'pad output to this width' = 0, + add_spaces: 'add a space after every 5th character' = True): "convert a hexadecimal string to die roll base6 (base6d)" from ..baseconv import baseconv from ..util import block_format ret = baseconv('b6d').fromhex(hexstr,pad,tostr=True) return block_format( ret, gw=5, cols=None ).strip() if add_spaces else ret - def b6dtohex(self,b6d_str:'sstr',pad=0): + def b6dtohex(self,b6d_str:'sstr',pad: 'pad output to this width' = 0): "convert a die roll base6 (base6d) string to hexadecimal" from ..baseconv import baseconv from ..util import remove_whitespace diff --git a/test/test_py_d/ts_misc.py b/test/test_py_d/ts_misc.py index ce632522..aa88e075 100755 --- a/test/test_py_d/ts_misc.py +++ b/test/test_py_d/ts_misc.py @@ -54,6 +54,7 @@ class TestSuiteHelp(TestSuiteBase): ('longhelpscreens', (1,'help screens (--longhelp)',[])), ('show_hash_presets', (1,'info screen (--show-hash-presets)',[])), ('tool_help', (1,"'mmgen-tool' usage screen",[])), + ('tool_cmd_usage', (1,"'mmgen-tool' usage screen",[])), ('test_help', (1,"'test.py' help screens",[])), ) @@ -114,6 +115,19 @@ class TestSuiteHelp(TestSuiteBase): t = self.spawn_chk('mmgen-tool',args,extra_desc=f"('mmgen-tool {fmt_list(args,fmt='bare')}')") return t + def tool_cmd_usage(self): + + if os.getenv('PYTHONOPTIMIZE') == '2': + ymsg('Skipping tool cmd usage with PYTHONOPTIMIZE=2 (no docstrings)') + return 'skip' + + from mmgen.main_tool import mods + + for cmdlist in mods.values(): + for cmd in cmdlist: + t = self.spawn_chk( 'mmgen-tool', ['help',cmd], extra_desc=f'({cmd})' ) + return t + def test_help(self): for args in ( ['--help'],