From d7e3b55e3bc18e052a97768912c09e2a4b15e673 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Tue, 8 Oct 2024 12:56:02 +0000 Subject: [PATCH] opts, help: refactor, parse cmdline opts natively, filter global opts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - command-line options are now parsed natively, without use of the getopt module - global options and --longhelp helpscreen are now contextual, depending on coin and executed command - commands invoked with out-of-context global options (e.g. `mmgen-walletgen --coin=btc`) now fail with an ‘unrecognized option’ error Testing: $ test/test-release.sh help $ test/cmdtest.py opts --- mmgen/cfg.py | 12 +- mmgen/data/release_date | 2 +- mmgen/data/version | 2 +- mmgen/exception.py | 1 + mmgen/help/__init__.py | 325 ++++++++++------------------------ mmgen/help/help_notes.py | 268 ++++++++++++++++++++++++++++ mmgen/opts.py | 327 ++++++++++++++++++++++++----------- mmgen/share/Opts.py | 182 ------------------- mmgen/share/__init__.py | 0 pyproject.toml | 2 + setup.cfg | 1 - test/cmdtest_py_d/ct_help.py | 2 +- test/cmdtest_py_d/ct_opts.py | 162 ++++++++++++++++- test/misc/opts_main.py | 14 ++ 14 files changed, 760 insertions(+), 540 deletions(-) create mode 100755 mmgen/help/help_notes.py delete mode 100755 mmgen/share/Opts.py delete mode 100755 mmgen/share/__init__.py diff --git a/mmgen/cfg.py b/mmgen/cfg.py index 64eac0ae..dd3c4994 100755 --- a/mmgen/cfg.py +++ b/mmgen/cfg.py @@ -51,7 +51,10 @@ class GlobalConstants(Lockable): min_time_precision = 18 # must match CoinProtocol.coins - core_coins = ('btc','bch','ltc','eth','etc','zec','xmr') + core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr') + rpc_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'xmr') + btc_fork_rpc_coins = ('btc', 'bch', 'ltc') + eth_fork_coins = ('eth', 'etc') _cc = namedtuple('cmd_cap', ['proto', 'rpc', 'coin', 'caps', 'platforms']) cmd_caps_data = { @@ -461,8 +464,8 @@ class Config(Lockable): opts_data = opts_data, init_opts = init_opts, opt_filter = opt_filter, - parse_only = parse_only, - parsed_opts = parsed_opts ) + parsed_opts = parsed_opts, + need_proto = need_proto) self._uopt_desc = 'command-line option' else: if cfg is None: @@ -575,7 +578,8 @@ class Config(Lockable): def _post_init(self): if self.help or self.longhelp: - self._opts.init_bottom(self) # exits + from .help import print_help + print_help(self, self._opts) # exits del self._opts def _usage(self): diff --git a/mmgen/data/release_date b/mmgen/data/release_date index 69bfda8c..922a67b5 100644 --- a/mmgen/data/release_date +++ b/mmgen/data/release_date @@ -1 +1 @@ -September 2024 +October 2024 diff --git a/mmgen/data/version b/mmgen/data/version index 0fe77a34..1ffbd58a 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev1 +15.1.dev2 diff --git a/mmgen/exception.py b/mmgen/exception.py index 18daea2e..61ed37c8 100755 --- a/mmgen/exception.py +++ b/mmgen/exception.py @@ -48,6 +48,7 @@ class FileNotFound(Exception): mmcode = 1 class InvalidPasswdFormat(Exception): mmcode = 1 class CfgFileParseError(Exception): mmcode = 1 class UserOptError(Exception): mmcode = 1 +class CmdlineOptError(Exception): mmcode = 1 class NoLEDSupport(Exception): mmcode = 1 class MsgFileFailedSID(Exception): mmcode = 1 class TestSuiteException(Exception): mmcode = 1 diff --git a/mmgen/help/__init__.py b/mmgen/help/__init__.py index 2996b7e7..9a9a3d69 100755 --- a/mmgen/help/__init__.py +++ b/mmgen/help/__init__.py @@ -20,7 +20,7 @@ help: help notes for MMGen suite commands """ -import sys +import sys, re from ..cfg import gc @@ -60,261 +60,110 @@ def usage(cfg): print(make_usage_str(cfg, caller='user')) sys.exit(0) -def help_notes_func(proto,cfg,k): +class Help: - def fee_spec_letters(use_quotes=False): - cu = proto.coin_amt.units - sep,conj = ((',',' or '),("','","' or '"))[use_quotes] - return sep.join(u[0] for u in cu[:-1]) + ('',conj)[len(cu)>1] + cu[-1][0] + def make(self, cfg, opts, proto): - def fee_spec_names(): - cu = proto.coin_amt.units - return ', '.join(cu[:-1]) + ('',' and ')[len(cu)>1] + cu[-1] + ('',',\nrespectively')[len(cu)>1] + def gen_arg_tuple(func, text): - def coind_exec(): - from ..daemon import CoinDaemon - return ( - CoinDaemon(cfg,proto.coin).exec_fn if proto.coin in CoinDaemon.coins else 'bitcoind' ) + def help_notes(k): + import importlib + return getattr(importlib.import_module( + f'{opts.help_pkg}.help_notes').help_notes(proto, cfg), k)() - class help_notes: + def help_mod(modname): + import importlib + return importlib.import_module( + f'{opts.help_pkg}.{modname}').help(proto, cfg) - def dfl_twname(): - from ..proto.btc.rpc import BitcoinRPCClient - return BitcoinRPCClient.dfl_twname + d = { + 'proto': proto, + 'help_notes': help_notes, + 'help_mod': help_mod, + 'cfg': cfg, + } + for arg in func.__code__.co_varnames: + yield d[arg] if arg in d else text - def MasterShareIdx(): - from ..seedsplit import MasterShareIdx - return MasterShareIdx + def gen_output(): + yield ' {} {}'.format(gc.prog_name.upper() + ':', text['desc'].strip()) + yield make_usage_str(cfg, caller='help') + yield help_type.upper().replace('_', ' ') + ':' - def tool_help(): - from ..tool.help import main_help - return main_help() + # process code for options + opts_text = nl.join(self.gen_text(opts)) + if help_type in code: + yield code[help_type](*tuple(gen_arg_tuple(code[help_type], opts_text))) + else: + yield opts_text - def dfl_subseeds(): - from ..subseed import SubSeedList - return str(SubSeedList.dfl_len) + # process code for notes + if help_type == 'options' and 'notes' in text: + if 'notes' in code: + yield from code['notes'](*tuple(gen_arg_tuple(code['notes'], text['notes']))).splitlines() + else: + yield from text['notes'].splitlines() - def dfl_seed_len(): - from ..seed import Seed - return str(Seed.dfl_len) + text = opts.opts_data['text'] + code = opts.opts_data['code'] + help_type = self.help_type + nl = '\n ' - def password_formats(): - from ..passwdlist import PasswordList - pwi_fs = '{:8} {:1} {:26} {:<7} {:<7} {}' - return '\n '.join( - [pwi_fs.format('Code','','Description','Min Len','Max Len','Default Len')] + - [pwi_fs.format(k,'-',v.desc,v.min_len,v.max_len,v.dfl_len) for k,v in PasswordList.pw_info.items()] - ) + return nl.join(gen_output()) + '\n' - def dfl_mmtype(): - from ..addr import MMGenAddrType - return "'{}' or '{}'".format( - proto.dfl_mmtype, - MMGenAddrType.mmtypes[proto.dfl_mmtype].name ) +class CmdHelp(Help): - def address_types(): - from ..addr import MMGenAddrType - return '\n '.join([ - "'{}','{:<12} - {}".format( k, v.name+"'", v.desc ) - for k,v in MMGenAddrType.mmtypes.items() - ]) + help_type = 'options' - def fmt_codes(): - from ..wallet import format_fmt_codes - return '\n '.join( format_fmt_codes().splitlines() ) + def gen_text(self, opts): + opt_filter = opts.opt_filter + from ..opts import cmd_opts_pat + skipping = False + for line in opts.opts_data['text']['options'].strip().splitlines(): + if m := cmd_opts_pat.match(line): + if opt_filter: + if m[1] in opt_filter: + skipping = False + else: + skipping = True + continue + yield '{} --{} {}'.format( + (f'-{m[1]},', ' ')[m[1] == '-'], + m[2], + m[4]) + elif not skipping: + yield line - def coin_id(): - return proto.coin_id +class GlobalHelp(Help): - def keygen_backends(): - from ..keygen import get_backends - from ..addr import MMGenAddrType - backends = get_backends( - MMGenAddrType(proto,cfg.type or proto.dfl_mmtype).pubkey_type - ) - return ' '.join( f'{n}:{k}{" [default]" if n==1 else ""}' for n,k in enumerate(backends,1) ) + help_type = 'global_options' - def coind_exec(): - return coind_exec() + def gen_text(self, opts): + from ..opts import global_opts_pat + for line in opts.global_opts_data['text'][1:-2].splitlines(): + if m := global_opts_pat.match(line): + if m[1] in opts.global_opts_filter.coin and m[2] in opts.global_opts_filter.cmd: + yield ' --{} {}'.format(m[3], m[5]) + skipping = False + else: + skipping = True + elif not skipping: + yield line[4:] - def coin_daemon_network_ids(): - from ..daemon import CoinDaemon - from ..util import fmt_list - return fmt_list(CoinDaemon.get_network_ids(cfg),fmt='bare') +def print_help(cfg, opts): - def rel_fee_desc(): - from ..tx import BaseTX - return BaseTX(cfg=cfg,proto=proto).rel_fee_desc + from ..protocol import init_proto_from_cfg + proto = init_proto_from_cfg(cfg, need_amt=True) - def fee_spec_letters(): - return fee_spec_letters() + if not 'code' in opts.opts_data: + opts.opts_data['code'] = {} - def fee(): - from ..tx import BaseTX - return """ - FEE SPECIFICATION + if cfg.help: + cls = CmdHelp + else: + opts.opts_data['code']['global_options'] = opts.global_opts_data['code'] + cls = GlobalHelp -Transaction fees, both on the command line and at the interactive prompt, may -be specified as either absolute {c} amounts, using a plain decimal number, or -as {r}, using an integer followed by '{l}', for {u}. -""".format( - c = proto.coin, - r = BaseTX(cfg=cfg,proto=proto).rel_fee_desc, - l = fee_spec_letters(use_quotes=True), - u = fee_spec_names() ) - - def passwd(): - return """ -PASSPHRASE NOTE: - -For passphrases all combinations of whitespace are equal, and leading and -trailing space are ignored. This permits reading passphrase or brainwallet -data from a multi-line file with free spacing and indentation. -""".strip() - - def brainwallet(): - return """ -BRAINWALLET NOTE: - -To thwart dictionary attacks, it’s recommended to use a strong hash preset -with brainwallets. For a brainwallet passphrase to generate the correct -seed, the same seed length and hash preset parameters must always be used. -""".strip() - - def txcreate_examples(): - - mmtype = 'B' if 'B' in proto.mmtypes else proto.mmtypes[0] - from ..tool.coin import tool_cmd - t = tool_cmd(cfg, mmtype=mmtype) - addr = t.privhex2addr('bead' * 16) - sample_addr = addr.views[addr.view_pref] - - return f""" -EXAMPLES: - - Send 0.123 {proto.coin} to an external {proto.name} address, returning the change to a - specific MMGen address in the tracking wallet: - - $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7 - - Same as above, but select the change address automatically: - - $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype} - - Same as above, but select the change address automatically by address type: - - $ {gc.prog_name} {sample_addr},0.123 {mmtype} - - Same as above, but reduce verbosity and specify fee of 20 satoshis - per byte: - - $ {gc.prog_name} -q -f 20s {sample_addr},0.123 {mmtype} - - Send entire balance of selected inputs minus fee to an external {proto.name} - address: - - $ {gc.prog_name} {sample_addr} - - Send entire balance of selected inputs minus fee to first unused wallet - address of specified type: - - $ {gc.prog_name} {mmtype} -""" - - def txcreate(): - return f""" -The transaction’s outputs are listed on the command line, while its inputs -are chosen from a list of the wallet’s unspent outputs via an interactive -menu. Alternatively, inputs may be specified using the --inputs option. - -All addresses on the command line can be either {proto.name} addresses or MMGen -IDs in the form :
:. - -Outputs are specified in the form
,, with the change output -specified by address only. Alternatively, the change output may be an -addrlist ID in the form :
, in which case the -first unused address in the tracking wallet matching the requested ID will -be automatically selected as the change output. - -If the transaction fee is not specified on the command line (see FEE -SPECIFICATION below), it will be calculated dynamically using network fee -estimation for the default (or user-specified) number of confirmations. -If network fee estimation fails, the user will be prompted for a fee. - -Network-estimated fees will be multiplied by the value of --fee-adjust, if -specified. - -To send the value of all inputs (minus TX fee) to a single output, specify -a single address with no amount on the command line. Alternatively, an -addrlist ID may be specified, and the address will be chosen automatically -as described above for the change output. -""" - - def txsign(): - from ..proto.btc.params import mainnet - return """ -Transactions may contain both {pnm} or non-{pnm} input addresses. - -To sign non-{pnm} inputs, a {wd}flat key list is used -as the key source (--keys-from-file option). - -To sign {pnm} inputs, key data is generated from a seed as with the -{pnl}-addrgen and {pnl}-keygen commands. Alternatively, a key-address file -may be used (--mmgen-keys-from-file option). - -Multiple wallets or other seed files can be listed on the command line in -any order. If the seeds required to sign the transaction’s inputs are not -found in these files (or in the default wallet), the user will be prompted -for seed data interactively. - -To prevent an attacker from crafting transactions with bogus {pnm}-to-{pnu} -address mappings, all outputs to {pnm} addresses are verified with a seed -source. Therefore, seed files or a key-address file for all {pnm} outputs -must also be supplied on the command line if the data can’t be found in the -default wallet. -""".format( - wd = (f'{coind_exec()} wallet dump or ' if isinstance(proto,mainnet) else ''), - pnm = gc.proj_name, - pnu = proto.name, - pnl = gc.proj_name.lower() ) - - def subwallet(): - from ..subseed import SubSeedIdxRange - return f""" -SUBWALLETS: - -Subwallets (subseeds) are specified by a ‘Subseed Index’ consisting of: - - a) an integer in the range 1-{SubSeedIdxRange.max_idx}, plus - b) an optional single letter, ‘L’ or ‘S’ - -The letter designates the length of the subseed. If omitted, ‘L’ is assumed. - -Long (‘L’) subseeds are the same length as their parent wallet’s seed -(typically 256 bits), while short (‘S’) subseeds are always 128-bit. -The long and short subseeds for a given index are derived independently, -so both may be used. - -MMGen Wallet has no notion of ‘depth’, and to an outside observer subwallets -are identical to ordinary wallets. This is a feature rather than a bug, as -it denies an attacker any way of knowing whether a given wallet has a parent. - -Since subwallets are just wallets, they may be used to generate other -subwallets, leading to hierarchies of arbitrary depth. However, this is -inadvisable in practice for two reasons: Firstly, it creates accounting -complexity, requiring the user to independently keep track of a derivation -tree. More importantly, however, it leads to the danger of Seed ID -collisions between subseeds at different levels of the hierarchy, as -MMGen checks and avoids ID collisions only among sibling subseeds. - -An exception to this caveat would be a multi-user setup where sibling -subwallets are distributed to different users as their default wallets. -Since the subseeds derived from these subwallets are private to each user, -Seed ID collisions among them doesn’t present a problem. - -A safe rule of thumb, therefore, is for *each user* to derive all of his/her -subwallets from a single parent. This leaves each user with a total of two -million subwallets, which should be enough for most practical purposes. -""".strip() - - return getattr(help_notes,k)() + from ..ui import do_pager + do_pager(cls().make(cfg, opts, proto)) + sys.exit(0) diff --git a/mmgen/help/help_notes.py b/mmgen/help/help_notes.py new file mode 100755 index 00000000..64d2adbf --- /dev/null +++ b/mmgen/help/help_notes.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2024 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +help: help notes functions for MMGen suite commands +""" + +from ..cfg import gc + +class help_notes: + + def __init__(self, proto, cfg): + self.proto = proto + self.cfg = cfg + + def fee_spec_letters(self, use_quotes=False): + cu = self.proto.coin_amt.units + sep, conj = ((',', ' or '), ("','", "' or '"))[use_quotes] + return sep.join(u[0] for u in cu[:-1]) + ('', conj)[len(cu)>1] + cu[-1][0] + + def fee_spec_names(self): + cu = self.proto.coin_amt.units + return ', '.join(cu[:-1]) + ('', ' and ')[len(cu)>1] + cu[-1] + ('', ',\nrespectively')[len(cu)>1] + + def coind_exec(self): + from ..daemon import CoinDaemon + return ( + CoinDaemon(self.cfg, self.proto.coin).exec_fn if self.proto.coin in CoinDaemon.coins else 'bitcoind') + + def dfl_twname(self): + from ..proto.btc.rpc import BitcoinRPCClient + return BitcoinRPCClient.dfl_twname + + def MasterShareIdx(self): + from ..seedsplit import MasterShareIdx + return MasterShareIdx + + def tool_help(self): + from ..tool.help import main_help + return main_help() + + def dfl_subseeds(self): + from ..subseed import SubSeedList + return str(SubSeedList.dfl_len) + + def dfl_seed_len(self): + from ..seed import Seed + return str(Seed.dfl_len) + + def password_formats(self): + from ..passwdlist import PasswordList + pwi_fs = '{:8} {:1} {:26} {:<7} {:<7} {}' + return '\n '.join( + [pwi_fs.format('Code', '','Description', 'Min Len', 'Max Len', 'Default Len')] + + [pwi_fs.format(k, '-', v.desc, v.min_len, v.max_len, v.dfl_len) + for k, v in PasswordList.pw_info.items()] + ) + + def dfl_mmtype(self): + from ..addr import MMGenAddrType + return "'{}' or '{}'".format(self.proto.dfl_mmtype, MMGenAddrType.mmtypes[self.proto.dfl_mmtype].name) + + def address_types(self): + from ..addr import MMGenAddrType + return '\n '.join([ + "'{}','{:<12} - {}".format(k, v.name + "'", v.desc) + for k, v in MMGenAddrType.mmtypes.items() + ]) + + def fmt_codes(self): + from ..wallet import format_fmt_codes + return '\n '.join(format_fmt_codes().splitlines()) + + def coin_id(self): + return self.proto.coin_id + + def keygen_backends(self): + from ..keygen import get_backends + from ..addr import MMGenAddrType + backends = get_backends( + MMGenAddrType(self.proto, self.cfg.type or self.proto.dfl_mmtype).pubkey_type + ) + return ' '.join('{n}:{k}{t}'.format(n=n, k=k, t=('', ' [default]')[n == 1]) + for n, k in enumerate(backends, 1)) + + def coin_daemon_network_ids(self): + from ..daemon import CoinDaemon + from ..util import fmt_list + return fmt_list(CoinDaemon.get_network_ids(self.cfg), fmt='bare') + + def rel_fee_desc(self): + from ..tx import BaseTX + return BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc + + def fee(self): + from ..tx import BaseTX + return """ + FEE SPECIFICATION + +Transaction fees, both on the command line and at the interactive prompt, may +be specified as either absolute {c} amounts, using a plain decimal number, or +as {r}, using an integer followed by '{l}', for {u}. +""".format( + c = self.proto.coin, + r = BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc, + l = self.fee_spec_letters(use_quotes=True), + u = self.fee_spec_names() ) + + def passwd(self): + return """ +PASSPHRASE NOTE: + +For passphrases all combinations of whitespace are equal, and leading and +trailing space are ignored. This permits reading passphrase or brainwallet +data from a multi-line file with free spacing and indentation. +""".strip() + + def brainwallet(self): + return """ +BRAINWALLET NOTE: + +To thwart dictionary attacks, it’s recommended to use a strong hash preset +with brainwallets. For a brainwallet passphrase to generate the correct +seed, the same seed length and hash preset parameters must always be used. +""".strip() + + def txcreate_examples(self): + + mmtype = 'B' if 'B' in self.proto.mmtypes else self.proto.mmtypes[0] + from ..tool.coin import tool_cmd + t = tool_cmd(self.cfg, mmtype=mmtype) + addr = t.privhex2addr('bead' * 16) + sample_addr = addr.views[addr.view_pref] + + return f""" +EXAMPLES: + + Send 0.123 {self.proto.coin} to an external {self.proto.name} address, returning the change to a + specific MMGen address in the tracking wallet: + + $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7 + + Same as above, but select the change address automatically: + + $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype} + + Same as above, but select the change address automatically by address type: + + $ {gc.prog_name} {sample_addr},0.123 {mmtype} + + Same as above, but reduce verbosity and specify fee of 20 satoshis + per byte: + + $ {gc.prog_name} -q -f 20s {sample_addr},0.123 {mmtype} + + Send entire balance of selected inputs minus fee to an external {self.proto.name} + address: + + $ {gc.prog_name} {sample_addr} + + Send entire balance of selected inputs minus fee to first unused wallet + address of specified type: + + $ {gc.prog_name} {mmtype} +""" + + def txcreate(self): + return f""" +The transaction’s outputs are listed on the command line, while its inputs +are chosen from a list of the wallet’s unspent outputs via an interactive +menu. Alternatively, inputs may be specified using the --inputs option. + +All addresses on the command line can be either {self.proto.name} addresses or MMGen +IDs in the form :
:. + +Outputs are specified in the form
,, with the change output +specified by address only. Alternatively, the change output may be an +addrlist ID in the form :
, in which case the +first unused address in the tracking wallet matching the requested ID will +be automatically selected as the change output. + +If the transaction fee is not specified on the command line (see FEE +SPECIFICATION below), it will be calculated dynamically using network fee +estimation for the default (or user-specified) number of confirmations. +If network fee estimation fails, the user will be prompted for a fee. + +Network-estimated fees will be multiplied by the value of --fee-adjust, if +specified. + +To send the value of all inputs (minus TX fee) to a single output, specify +a single address with no amount on the command line. Alternatively, an +addrlist ID may be specified, and the address will be chosen automatically +as described above for the change output. +""" + + def txsign(self): + from ..proto.btc.params import mainnet + return """ +Transactions may contain both {pnm} or non-{pnm} input addresses. + +To sign non-{pnm} inputs, a {wd}flat key list is used +as the key source (--keys-from-file option). + +To sign {pnm} inputs, key data is generated from a seed as with the +{pnl}-addrgen and {pnl}-keygen commands. Alternatively, a key-address file +may be used (--mmgen-keys-from-file option). + +Multiple wallets or other seed files can be listed on the command line in +any order. If the seeds required to sign the transaction’s inputs are not +found in these files (or in the default wallet), the user will be prompted +for seed data interactively. + +To prevent an attacker from crafting transactions with bogus {pnm}-to-{pnu} +address mappings, all outputs to {pnm} addresses are verified with a seed +source. Therefore, seed files or a key-address file for all {pnm} outputs +must also be supplied on the command line if the data can’t be found in the +default wallet. +""".format( + wd = f'{self.coind_exec()} wallet dump or ' if isinstance(self.proto, mainnet) else '', + pnm = gc.proj_name, + pnu = self.proto.name, + pnl = gc.proj_name.lower()) + + def subwallet(self): + from ..subseed import SubSeedIdxRange + return f""" +SUBWALLETS: + +Subwallets (subseeds) are specified by a ‘Subseed Index’ consisting of: + + a) an integer in the range 1-{SubSeedIdxRange.max_idx}, plus + b) an optional single letter, ‘L’ or ‘S’ + +The letter designates the length of the subseed. If omitted, ‘L’ is assumed. + +Long (‘L’) subseeds are the same length as their parent wallet’s seed +(typically 256 bits), while short (‘S’) subseeds are always 128-bit. +The long and short subseeds for a given index are derived independently, +so both may be used. + +MMGen Wallet has no notion of ‘depth’, and to an outside observer subwallets +are identical to ordinary wallets. This is a feature rather than a bug, as +it denies an attacker any way of knowing whether a given wallet has a parent. + +Since subwallets are just wallets, they may be used to generate other +subwallets, leading to hierarchies of arbitrary depth. However, this is +inadvisable in practice for two reasons: Firstly, it creates accounting +complexity, requiring the user to independently keep track of a derivation +tree. More importantly, however, it leads to the danger of Seed ID +collisions between subseeds at different levels of the hierarchy, as +MMGen checks and avoids ID collisions only among sibling subseeds. + +An exception to this caveat would be a multi-user setup where sibling +subwallets are distributed to different users as their default wallets. +Since the subseeds derived from these subwallets are private to each user, +Seed ID collisions among them doesn’t present a problem. + +A safe rule of thumb, therefore, is for *each user* to derive all of his/her +subwallets from a single parent. This leaves each user with a total of two +million subwallets, which should be enough for most practical purposes. +""".strip() diff --git a/mmgen/opts.py b/mmgen/opts.py index 92a9c224..ac009e34 100755 --- a/mmgen/opts.py +++ b/mmgen/opts.py @@ -17,13 +17,128 @@ # along with this program. If not, see . """ -opts: MMGen-specific command-line options processing after generic processing by share.Opts +opts: command-line options processing for the MMGen Project """ -import sys,os -from .share import Opts +import sys, os, re +from collections import namedtuple + from .cfg import gc +def get_opt_by_substring(opt, opts): + matches = [o for o in opts if o.startswith(opt)] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + from .util import die + die('CmdlineOptError', f'--{opt}: ambiguous option (not unique substring)') + +def process_uopts(opts_data, opts): + + from .util import die + + def get_uopts(): + nonlocal uargs + idx = 1 + argv_len = len(sys.argv) + while idx < argv_len: + arg = sys.argv[idx] + if len(arg) > 4096: + raise RuntimeError(f'{len(arg)} bytes: command-line argument too long') + if arg.startswith('--'): + if len(arg) == 2: + uargs = sys.argv[idx+1:] + return + opt, parm = arg[2:].split('=') if '=' in arg else (arg[2:], None) + if len(opt) < 2: + die('CmdlineOptError', f'--{opt}: option name must be at least two characters long') + if opt in opts or (opt := get_opt_by_substring(opt, opts)): + if opts[opt].has_parm: + if parm: + yield (opts[opt].name, parm) + else: + idx += 1 + if idx == argv_len or (parm := sys.argv[idx]).startswith('-'): + die('CmdlineOptError', f'missing parameter for option --{opt}') + yield (opts[opt].name, parm) + else: + if parm: + die('CmdlineOptError', f'option --{opt} requires no parameter') + yield (opts[opt].name, True) + else: + opt, parm = arg[2:].split('=') if '=' in arg else (arg[2:], None) + die('CmdlineOptError', f'--{opt}: unrecognized option') + elif arg[0] == '-' and len(arg) > 1: + for j, sopt in enumerate(arg[1:]): + if sopt in opts: + if opts[sopt].has_parm: + if j > 0: + die('CmdlineOptError', f'{arg}: short option with parameters cannot be combined') + if arg[2:]: + yield (opts[sopt].name, arg[2:]) + else: + idx += 1 + if idx == argv_len or (parm := sys.argv[idx]).startswith('-'): + die('CmdlineOptError', f'missing parameter for option -{sopt}') + yield (opts[sopt].name, parm) + break + else: + yield (opts[sopt].name, True) + else: + die('CmdlineOptError', f'-{sopt}: unrecognized option') + else: + uargs = sys.argv[idx:] + return + idx += 1 + + uargs = [] + uopts = dict(get_uopts()) + + if 'sets' in opts_data: + for a_opt, a_val, b_opt, b_val in opts_data['sets']: + if a_opt in uopts: + u_val = uopts[a_opt] + if (u_val and a_val == bool) or u_val == a_val: + if b_opt in uopts and uopts[b_opt] != b_val: + die(1, + 'Option conflict:' + + '\n --{}={}, with'.format(b_opt.replace('_', '-'), uopts[b_opt]) + + '\n --{}={}\n'.format(a_opt.replace('_', '-'), uopts[a_opt])) + else: + uopts[b_opt] = b_val + + return uopts, uargs + +cmd_opts_pat = re.compile(r'^-([a-zA-Z0-9-]), --([a-zA-Z0-9-]{2,64})(=| )(.+)') +global_opts_pat = re.compile(r'^\t\t\t(.)(.) --([a-zA-Z0-9-]{2,64})(=| )(.+)') +ao = namedtuple('opt', ['name', 'has_parm']) + +def parse_opts(opts_data, opt_filter, global_opts_data, global_opts_filter): + + def parse_cmd_opts_text(): + for line in opts_data['text']['options'].strip().splitlines(): + m = cmd_opts_pat.match(line) + if m and (not opt_filter or m[1] in opt_filter): + ret = ao(m[2].replace('-', '_'), m[3] == '=') + yield (m[1], ret) + yield (m[2], ret) + + def parse_global_opts_text(): + for line in global_opts_data['text'].splitlines(): + m = global_opts_pat.match(line) + if m and m[1] in global_opts_filter.coin and m[2] in global_opts_filter.cmd: + yield (m[3], ao(m[3].replace('-', '_'), m[4] == '=')) + + opts = tuple(parse_cmd_opts_text()) + tuple(parse_global_opts_text()) + + uopts, uargs = process_uopts(opts_data, dict(opts)) + + return namedtuple('parsed_cmd_opts', ['user_opts', 'cmd_args', 'opts'])( + uopts, # dict + uargs, # list, callers can pop + tuple(v.name for k,v in opts if len(k) > 1) + ) + def opt_preproc_debug(po): d = ( ('Cmdline', ' '.join(sys.argv), False), @@ -37,48 +152,6 @@ def opt_preproc_debug(po): for label,data,pretty in d: Msg(' {:<20}: {}'.format(label,'\n' + fmt_list(data,fmt='col',indent=' '*8) if pretty else data)) -long_opts_data = { - 'text': """ ---, --accept-defaults Accept defaults at all prompts ---, --coin=c Choose coin unit. Default: BTC. Current choice: {cu_dfl} ---, --token=t Specify an ERC20 token by address or symbol ---, --cashaddr=0|1 Display BCH addresses in cashaddr format (default: 1) ---, --color=0|1 Disable or enable color output (default: 1) ---, --columns=N Force N columns of output with certain commands ---, --scroll Use the curses-like scrolling interface for - tracking wallet views ---, --force-256-color Force 256-color output when color is enabled ---, --pager Pipe output of certain commands to pager (WIP) ---, --data-dir=path Specify {pnm} data directory location ---, --daemon-data-dir=path Specify coin daemon data directory location ---, --daemon-id=ID Specify the coin daemon ID ---, --ignore-daemon-version Ignore coin daemon version check ---, --http-timeout=t Set HTTP timeout in seconds for JSON-RPC connections ---, --no-license Suppress the GPL license prompt ---, --rpc-host=HOST Communicate with coin daemon running on host HOST ---, --rpc-port=PORT Communicate with coin daemon listening on port PORT ---, --rpc-user=USER Authenticate to coin daemon using username USER ---, --rpc-password=PASS Authenticate to coin daemon using password PASS ---, --rpc-backend=backend Use backend 'backend' for JSON-RPC communications ---, --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp ---, --regtest=0|1 Disable or enable regtest mode ---, --testnet=0|1 Disable or enable testnet ---, --tw-name=NAME Specify alternate name for the BTC/LTC/BCH tracking - wallet (default: ‘{tw_name}’) ---, --skip-cfg-file Skip reading the configuration file ---, --usage Print usage information and exit ---, --version Print version information and exit ---, --bob Specify user “Bob” in MMGen regtest mode ---, --alice Specify user “Alice” in MMGen regtest mode ---, --carol Specify user “Carol” in MMGen regtest mode - """, - 'code': lambda proto,help_notes,s: s.format( - pnm = gc.proj_name, - cu_dfl = proto.coin, - tw_name = help_notes('dfl_twname') - ) -} - opts_data_dfl = { 'text': { 'desc': '', @@ -90,7 +163,22 @@ opts_data_dfl = { } } -class UserOpts: +def get_coin(): + for n, arg in enumerate(sys.argv[1:]): + if len(arg) > 4096: + raise RuntimeError(f'{len(arg)} bytes: command-line argument too long') + if arg.startswith('--coin='): + return arg.removeprefix('--coin=').lower() + if arg == '--coin': + if len(sys.argv) < n + 3: + from .util import die + die('CmdlineOptError', f'{arg}: missing parameter') + return sys.argv[n + 2].lower() + if arg == '-' or not arg.startswith('-'): # stop at first non-option + return 'btc' + return 'btc' + +class Opts: def __init__( self, @@ -98,16 +186,19 @@ class UserOpts: opts_data, init_opts, # dict containing opts to pre-initialize opt_filter, # whitelist of opt letters; all others are skipped - parse_only, - parsed_opts): + parsed_opts, + need_proto): - self.opts_data = od = opts_data or opts_data_dfl + if len(sys.argv) > 257: + raise RuntimeError(f'{len(sys.argv) - 1}: too many command-line arguments') + + opts_data = opts_data or opts_data_dfl self.opt_filter = opt_filter - od['text']['long_options'] = long_opts_data['text'] + self.global_opts_filter = self.get_global_opts_filter(need_proto) + self.opts_data = opts_data - # po: (user_opts,cmd_args,opts,filtered_opts) - po = parsed_opts or Opts.parse_opts(od,opt_filter=opt_filter) + po = parsed_opts or parse_opts(opts_data, opt_filter, self.global_opts_data, self.global_opts_filter) cfg._args = po.cmd_args cfg._uopts = uopts = po.user_opts @@ -127,60 +218,90 @@ class UserOpts: if os.getenv('MMGEN_DEBUG_OPTS'): opt_preproc_debug(po) - for funcname in ('usage', 'version', 'show_hash_presets'): + for funcname in self.info_funcs: if funcname in uopts: import importlib - getattr(importlib.import_module('mmgen.help'), funcname)(cfg) # exits + getattr(importlib.import_module(self.help_pkg), funcname)(cfg) # exits - if parse_only: - return +class UserOpts(Opts): - def init_bottom(self,cfg): - # print help screen only after globals initialized and locked: - if cfg.help or cfg.longhelp: - self.print_help(cfg) # exits + help_pkg = 'mmgen.help' + info_funcs = ('usage', 'version', 'show_hash_presets') - def usage(self): - from .util import Die - Die(1,Opts.make_usage_str(gc.prog_name,'user',self.usage_data)) + global_opts_data = { + # coin code : cmd code : opt : opt param : text + 'text': """ + -- --accept-defaults Accept defaults at all prompts + hp --cashaddr=0|1 Display addresses in cashaddr format (default: 1) + -p --coin=c Choose coin unit. Default: BTC. Current choice: {cu_dfl} + er --token=t Specify an ERC20 token by address or symbol + -- --color=0|1 Disable or enable color output (default: 1) + -- --columns=N Force N columns of output with certain commands + Rr --scroll Use the curses-like scrolling interface for + + tracking wallet views + -- --force-256-color Force 256-color output when color is enabled + -- --pager Pipe output of certain commands to pager (WIP) + -- --data-dir=path Specify {pnm} data directory location + rr --daemon-data-dir=path Specify coin daemon data directory location + Rr --daemon-id=ID Specify the coin daemon ID + rr --ignore-daemon-version Ignore coin daemon version check + rr --http-timeout=t Set HTTP timeout in seconds for JSON-RPC connections + -- --no-license Suppress the GPL license prompt + rr --rpc-host=HOST Communicate with coin daemon running on host HOST + rr --rpc-port=PORT Communicate with coin daemon listening on port PORT + rr --rpc-user=USER Authenticate to coin daemon using username USER + rr --rpc-password=PASS Authenticate to coin daemon using password PASS + Rr --rpc-backend=backend Use backend 'backend' for JSON-RPC communications + Rr --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp + -p --regtest=0|1 Disable or enable regtest mode + -- --testnet=0|1 Disable or enable testnet + br --tw-name=NAME Specify alternate name for the BTC/LTC/BCH tracking + + wallet (default: ‘{tw_name}’) + -- --skip-cfg-file Skip reading the configuration file + -- --version Print version information and exit + -- --usage Print usage information and exit + b- --bob Specify user ‘Bob’ in MMGen regtest mode + b- --alice Specify user ‘Alice’ in MMGen regtest mode + b- --carol Specify user ‘Carol’ in MMGen regtest mode + """, + 'code': lambda proto, help_notes, s: s.format( + pnm = gc.proj_name, + cu_dfl = proto.coin, + tw_name = help_notes('dfl_twname')) + } - def version(self): - from .util import Die,fmt - Die(0,fmt(f""" - {gc.prog_name.upper()} version {gc.version} - Part of {gc.proj_name} Wallet, an online/offline cryptocurrency wallet for the - command line. Copyright (C){gc.Cdates} {gc.author} {gc.email} - """,indent=' ').rstrip()) - - def print_help(self,cfg): - - if not 'code' in self.opts_data: - self.opts_data['code'] = {} - - from .protocol import init_proto_from_cfg - proto = init_proto_from_cfg(cfg,need_amt=True) - - if getattr(cfg,'longhelp',None): - self.opts_data['code']['long_options'] = long_opts_data['code'] - def remove_unneeded_long_opts(): - d = self.opts_data['text']['long_options'] - if proto.base_proto != 'Ethereum': - d = '\n'.join(''+i for i in d.split('\n') if not '--token' in i) - self.opts_data['text']['long_options'] = d - remove_unneeded_long_opts() - - from .ui import do_pager - do_pager(Opts.make_help( cfg, proto, self.opts_data, self.opt_filter )) - - sys.exit(0) - - def show_hash_presets(self): - fs = ' {:<6} {:<3} {:<2} {}' - from .util import msg - from .crypto import Crypto - msg(' Available parameters for scrypt.hash():') - msg(fs.format('Preset','N','r','p')) - for i in sorted(Crypto.hash_presets.keys()): - msg(fs.format(i,*Crypto.hash_presets[i])) - msg(' N = memory usage (power of two)\n p = iterations (rounds)') - sys.exit(0) + @staticmethod + def get_global_opts_filter(need_proto): + """ + Coin codes: + 'b' - Bitcoin or Bitcoin code fork supporting RPC + 'R' - Bitcoin or Ethereum code fork supporting RPC + 'e' - Ethereum or Ethereum code fork + 'r' - coin supporting RPC + 'h' - Bitcoin Cash + '-' - other coin + Cmd codes: + 'p' - proto required + 'r' - RPC required + '-' - no capabilities required + """ + ret = namedtuple('global_opts_filter', ['coin', 'cmd']) + if caps := gc.cmd_caps: + coin = caps.coin if caps.coin and len(caps.coin) > 1 else get_coin() + return ret( + coin = ( + ('-', 'r', 'R', 'b', 'h') if coin == 'bch' else + ('-', 'r', 'R', 'b') if coin in gc.btc_fork_rpc_coins else + ('-', 'r', 'R', 'e') if coin in gc.eth_fork_coins else + ('-', 'r') if coin in gc.rpc_coins else + ('-')), + cmd = ( + ['-'] + + (['r'] if caps.rpc else []) + + (['p'] if caps.proto else []) + )) + else: + return ret( + coin = ('-', 'r', 'R', 'b', 'h', 'e'), + cmd = ('-', 'r', 'p') + ) diff --git a/mmgen/share/Opts.py b/mmgen/share/Opts.py deleted file mode 100755 index e743cf5f..00000000 --- a/mmgen/share/Opts.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -# -# Opts.py, an options parsing library for Python. -# Copyright (C)2013-2024 The MMGen Project -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -""" -share.Opts: Generic options parsing -""" - -import sys,re -from collections import namedtuple - -pat = re.compile(r'^-([a-zA-Z0-9-]), --([a-zA-Z0-9-]{2,64})(=| )(.+)') - -def make_usage_str(prog_name,caller,data): - lines = [data.strip()] if isinstance(data,str) else data - indent,col1_w = { - 'help': (2,len(prog_name)+1), - 'user': (0,len('USAGE:')), - }[caller] - def gen(): - ulbl = 'USAGE:' - for line in lines: - yield f'{ulbl:{col1_w}} {prog_name} {line}' - ulbl = '' - return ('\n'+(' '*indent)).join(gen()) - -def usage(opts_data): - print(make_usage_str( - prog_name = opts_data['prog_name'], - caller = 'user', - data = opts_data['text'].get('usage2') or opts_data['text']['usage'] )) - sys.exit(1) - -def print_help(*args): - print(make_help(*args)) - sys.exit(0) - -def make_help(cfg,proto,opts_data,opt_filter): - - def parse_lines(text): - filtered = False - for line in text.strip().splitlines(): - m = pat.match(line) - if m: - filtered = bool(opt_filter and m[1] not in opt_filter) - if not filtered: - yield fs.format( ('-'+m[1]+',','')[m[1]=='-'], m[2], m[4] ) - elif not filtered: - yield line - - opts_type,fs = ('options','{:<3} --{} {}') if cfg.help else ('long_options','{} --{} {}') - t = opts_data['text'] - c = opts_data['code'] - nl = '\n ' - - pn = opts_data['prog_name'] - - from ..help import help_notes_func - def help_notes(k): - return help_notes_func(proto,cfg,k) - - def help_mod(modname): - import importlib - return importlib.import_module('mmgen.help.'+modname).help(proto,cfg) - - def gen_arg_tuple(func,text): - d = { - 'proto': proto, - 'help_notes': help_notes, - 'help_mod': help_mod, - 'cfg': cfg, - } - for arg in func.__code__.co_varnames: - yield d[arg] if arg in d else text - - def gen_text(): - yield ' {} {}'.format(pn.upper()+':',t['desc'].strip()) - yield make_usage_str(pn,'help',t.get('usage2') or t['usage']) - yield opts_type.upper().replace('_',' ') + ':' - - # process code for options - opts_text = nl.join(parse_lines(t[opts_type])) - if opts_type in c: - arg_tuple = tuple(gen_arg_tuple(c[opts_type],opts_text)) - yield c[opts_type](*arg_tuple) - else: - yield opts_text - - # process code for notes - if opts_type == 'options' and 'notes' in t: - notes_text = t['notes'] - if 'notes' in c: - arg_tuple = tuple(gen_arg_tuple(c['notes'],notes_text)) - notes_text = c['notes'](*arg_tuple) - yield from notes_text.splitlines() - - return nl.join(gen_text()) + '\n' - -def process_uopts(opts_data,short_opts,long_opts): - - import os,getopt - opts_data['prog_name'] = os.path.basename(sys.argv[0]) - - try: - cl_uopts,uargs = getopt.getopt(sys.argv[1:],''.join(short_opts),long_opts) - except getopt.GetoptError as e: - print(e.args[0]) - sys.exit(1) - - def get_uopts(): - for uopt,uparm in cl_uopts: - if uopt.startswith('--'): - lo = uopt[2:] - if lo in long_opts: - yield (lo.replace('-','_'), True) - else: # lo+'=' in long_opts - yield (lo.replace('-','_'), uparm) - else: # uopt.startswith('-') - so = uopt[1] - if so in short_opts: - yield (long_opts[short_opts.index(so)].replace('-','_'), True) - else: # so+':' in short_opts - yield (long_opts[short_opts.index(so+':')][:-1].replace('-','_'), uparm) - - uopts = dict(get_uopts()) - - if 'sets' in opts_data: - for a_opt,a_val,b_opt,b_val in opts_data['sets']: - if a_opt in uopts: - u_val = uopts[a_opt] - if (u_val and a_val == bool) or u_val == a_val: - if b_opt in uopts and uopts[b_opt] != b_val: - sys.stderr.write( - 'Option conflict:' - + '\n --{}={}, with'.format(b_opt.replace('_','-'),uopts[b_opt]) - + '\n --{}={}\n'.format(a_opt.replace('_','-'),uopts[a_opt]) ) - sys.exit(1) - else: - uopts[b_opt] = b_val - - return uopts,uargs - -def parse_opts(opts_data,opt_filter=None): - - short_opts,long_opts,filtered_opts = [],[],[] - def parse_lines(opts_type): - for line in opts_data['text'][opts_type].strip().splitlines(): - m = pat.match(line) - if m: - if opt_filter and m[1] not in opt_filter: - filtered_opts.append(m[2]) - else: - if opts_type == 'options': - short_opts.append(m[1] + ('',':')[m[3] == '=']) - long_opts.append(m[2] + ('','=')[m[3] == '=']) - - parse_lines('options') - if 'long_options' in opts_data['text']: - parse_lines('long_options') - - uopts,uargs = process_uopts(opts_data,short_opts,long_opts) - - return namedtuple('parsed_cmd_opts',['user_opts','cmd_args','opts','filtered_opts'])( - uopts, # dict - uargs, # list, callers can pop - tuple(o.replace('-','_').rstrip('=') for o in long_opts), - tuple(o.replace('-','_') for o in filtered_opts), - ) diff --git a/mmgen/share/__init__.py b/mmgen/share/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/pyproject.toml b/pyproject.toml index 7da1827d..a51bd826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,4 +82,6 @@ ignored-classes = [ # ignored for no-member, otherwise checked "GenTool", "VirtBlockDeviceBase", "SwapMgrBase", + "Opts", + "Help", ] diff --git a/setup.cfg b/setup.cfg index aaa6ebea..3e656a70 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,7 +86,6 @@ packages = mmgen.proto.secp256k1 mmgen.proto.xmr mmgen.proto.zec - mmgen.share mmgen.tool mmgen.tx mmgen.tw diff --git a/test/cmdtest_py_d/ct_help.py b/test/cmdtest_py_d/ct_help.py index 9baf4c99..e5360faa 100755 --- a/test/cmdtest_py_d/ct_help.py +++ b/test/cmdtest_py_d/ct_help.py @@ -117,7 +117,7 @@ class CmdTestHelp(CmdTestBase): return t def longhelpscreens(self): - return self.helpscreens(arg='--longhelp',expect='USAGE:.*LONG OPTIONS:') + return self.helpscreens(arg='--longhelp',expect='USAGE:.*GLOBAL OPTIONS:') def show_hash_presets(self): return self.helpscreens( diff --git a/test/cmdtest_py_d/ct_opts.py b/test/cmdtest_py_d/ct_opts.py index af710736..103a10e0 100755 --- a/test/cmdtest_py_d/ct_opts.py +++ b/test/cmdtest_py_d/ct_opts.py @@ -16,17 +16,44 @@ from ..include.common import cfg from .ct_base import CmdTestBase class CmdTestOpts(CmdTestBase): - 'options processing' + 'command-line options parsing and processing' networks = ('btc',) tmpdir_nums = [41] cmd_group = ( - ('opt_helpscreen', (41,"helpscreen output", [])), - ('opt_noargs', (41,"invocation with no user options or arguments", [])), - ('opt_good', (41,"good opts", [])), - ('opt_bad_infile', (41,"bad infile parameter", [])), - ('opt_bad_outdir', (41,"bad outdir parameter", [])), - ('opt_bad_incompatible', (41,"incompatible opts", [])), - ('opt_bad_autoset', (41,"invalid autoset value", [])), + ('opt_helpscreen', (41, 'helpscreen output', [])), + ('opt_noargs', (41, 'invocation with no user options or arguments', [])), + ('opt_good1', (41, 'good opts (long opts only)', [])), + ('opt_good2', (41, 'good opts (mixed short and long opts)', [])), + ('opt_good3', (41, 'good opts (max arg count)', [])), + ('opt_good4', (41, 'good opts (maxlen arg)', [])), + ('opt_good5', (41, 'good opts (long opt substring)', [])), + ('opt_good6', (41, 'good global opt (--coin=xmr)', [])), + ('opt_good7', (41, 'good global opt (--coin xmr)', [])), + ('opt_good8', (41, 'good global opt (--pager)', [])), + ('opt_good9', (41, 'good cmdline arg ‘-’', [])), + ('opt_good10', (41, 'good cmdline arg ‘-’ with arg', [])), + ('opt_good11', (41, 'good cmdline arg ‘-’ with option', [])), + ('opt_bad_param', (41, 'bad global opt (--pager=1)', [])), + ('opt_bad_infile', (41, 'bad infile parameter', [])), + ('opt_bad_outdir', (41, 'bad outdir parameter', [])), + ('opt_bad_incompatible', (41, 'incompatible opts', [])), + ('opt_bad_autoset', (41, 'invalid autoset value', [])), + ('opt_invalid_1', (41, 'invalid cmdline opt ‘--x’', [])), + ('opt_invalid_2', (41, 'invalid cmdline opt ‘---’', [])), + ('opt_invalid_3', (41, 'invalid cmdline opt (combined short opt with param)', [])), + ('opt_invalid_4', (41, 'invalid cmdline opt (combined short opt with param)', [])), + ('opt_invalid_5', (41, 'invalid cmdline opt (missing parameter)', [])), + ('opt_invalid_6', (41, 'invalid cmdline opt (missing parameter)', [])), + ('opt_invalid_7', (41, 'invalid cmdline opt (parameter not required)', [])), + ('opt_invalid_8', (41, 'invalid cmdline opt (non-existent option)', [])), + ('opt_invalid_9', (41, 'invalid cmdline opt (non-existent option)', [])), + ('opt_invalid_10', (41, 'invalid cmdline opt (missing parameter)', [])), + ('opt_invalid_11', (41, 'invalid cmdline opt (missing parameter)', [])), + ('opt_invalid_12', (41, 'invalid cmdline opt (non-existent option)', [])), + ('opt_invalid_13', (41, 'invalid cmdline opt (ambiguous long opt substring)', [])), + ('opt_invalid_14', (41, 'invalid cmdline opt (long opt substring too short)', [])), + ('opt_invalid_15', (41, 'invalid cmdline (too many args)', [])), + ('opt_invalid_16', (41, 'invalid cmdline (overlong arg)', [])), ) def spawn_prog(self, args, exit_val=None): @@ -65,10 +92,12 @@ class CmdTestOpts(CmdTestBase): ('cfg.outdir', ''), # check_outdir() ('cfg.cached_balances', 'False'), ('cfg.minconf', '1'), + ('cfg.coin', 'BTC'), + ('cfg.pager', 'False'), ('cfg.fee_estimate_mode', 'conservative'), # _autoset_opts )) - def opt_good(self): + def opt_good1(self): pf_base = 'testfile' pf = os.path.join(self.tmpdir,pf_base) self.write_to_tmpfile(pf_base,'') @@ -90,6 +119,68 @@ class CmdTestOpts(CmdTestBase): ('cfg.fee_estimate_mode', 'economical'), )) + def opt_good2(self): + return self.check_vals( + [ + '--print-checksum', + '-qX', + f'--outdir={self.tmpdir}', + '-p5', + '-m', '0', + '--seed-len=256', + '-L--my-label', + '--seed-len', '128', + '--min-temp=-30', + '-T-10', + '--', + 'x', 'y', '12345' + ], ( + ('cfg.print_checksum', 'True'), + ('cfg.quiet', 'True'), + ('cfg.outdir', self.tmpdir), + ('cfg.cached_balances', 'True'), + ('cfg.minconf', '0'), + ('cfg.keep_label', 'None'), + ('cfg.seed_len', '128'), + ('cfg.hash_preset', '5'), + ('cfg.label', '--my-label'), + ('cfg.min_temp', '-30'), + ('cfg.max_temp', '-10'), + ('arg1', 'x'), + ('arg2', 'y'), + ('arg3', '12345'), + )) + + def opt_good3(self): + return self.check_vals(['m'] * 256, (('arg256', 'm'),)) + + def opt_good4(self): + return self.check_vals(['e' * 4096], (('arg1', 'e' * 4096),)) + + def opt_good5(self): + return self.check_vals(['--minc=7'], (('cfg.minconf', '7'),)) + + def opt_good6(self): + return self.check_vals(['--coin=xmr'], (('cfg.coin', 'XMR'),)) + + def opt_good7(self): + return self.check_vals(['--coin', 'xmr'], (('cfg.coin', 'XMR'),)) + + def opt_good8(self): + return self.check_vals(['--pager'], (('cfg.pager', 'True'),)) + + def opt_good9(self): + return self.check_vals(['-'], (('arg1', '-'),)) + + def opt_good10(self): + return self.check_vals(['-', '-x'], (('arg1', '-'), ('arg2', '-x'))) + + def opt_good11(self): + return self.check_vals(['-q', '-', '-x'], (('arg1', '-'), ('arg2', '-x'))) + + def opt_bad_param(self): + return self.do_run(['--pager=1'], 'no parameter', 1) + def opt_bad_infile(self): pf = os.path.join(self.tmpdir,'fubar') return self.do_run(['--passwd-file='+pf],'not found',1) @@ -103,3 +194,56 @@ class CmdTestOpts(CmdTestBase): def opt_bad_autoset(self): return self.do_run(['--fee-estimate-mode=Fubar'],'not unique substring',1) + + def opt_invalid(self, args, expect, exit_val): + t = self.spawn_prog(args, exit_val=exit_val) + t.expect(expect) + return t + + def opt_invalid_1(self): + return self.opt_invalid(['--x'], 'must be at least', 1) + + def opt_invalid_2(self): + return self.opt_invalid(['---'], 'must be at least', 1) + + def opt_invalid_3(self): + return self.opt_invalid(['-kl3'], 'short option with parameters', 1) + + def opt_invalid_4(self): + return self.opt_invalid(['-kl 3'], 'short option with parameters', 1) + + def opt_invalid_5(self): + return self.opt_invalid(['-l'], 'missing parameter', 1) + + def opt_invalid_6(self): + return self.opt_invalid(['-l', '-k'], 'missing parameter', 1) + + def opt_invalid_7(self): + return self.opt_invalid(['--quiet=1'], 'requires no parameter', 1) + + def opt_invalid_8(self): + return self.opt_invalid(['-x'], 'unrecognized option', 1) + + def opt_invalid_9(self): + return self.opt_invalid(['--frobnicate'], 'unrecognized option', 1) + + def opt_invalid_10(self): + return self.opt_invalid(['--label', '-q'], 'missing parameter', 1) + + def opt_invalid_11(self): + return self.opt_invalid(['-T', '-10'], 'missing parameter', 1) + + def opt_invalid_12(self): + return self.opt_invalid(['-q', '-10'], 'unrecognized option', 1) + + def opt_invalid_13(self): + return self.opt_invalid(['--mi=3'], 'ambiguous option', 1) + + def opt_invalid_14(self): + return self.opt_invalid(['--m=3'], 'must be at least', 1) + + def opt_invalid_15(self): + return self.opt_invalid(['m'] * 257, 'too many', 1) + + def opt_invalid_16(self): + return self.opt_invalid(['e' * 4097], 'too long', 1) diff --git a/test/misc/opts_main.py b/test/misc/opts_main.py index a42cf645..9705c7ab 100755 --- a/test/misc/opts_main.py +++ b/test/misc/opts_main.py @@ -25,6 +25,8 @@ opts_data = { -p, --hash-preset= p Use the scrypt hash parameters defined by preset 'p' -P, --passwd-file= f Get wallet passphrase from file 'f' -q, --quiet Be quieter +-t, --min-temp= t Minimum temperature (in degrees Celsius) +-T, --max-temp= t Maximum temperature (in degrees Celsius) -X, --cached-balances Use cached balances (Ethereum only) -v, --verbose Be more verbose sample help_note: {kgs} @@ -57,6 +59,14 @@ for k in ( 'cached_balances', # opt_sets_global 'minconf', # global_sets_opt 'hidden_incog_input_params', + 'keep_label', + 'seed_len', + 'hash_preset', + 'label', + 'min_temp', + 'max_temp', + 'coin', + 'pager', ): msg('{:30} {}'.format( f'cfg.{k}:', getattr(cfg,k) )) @@ -72,3 +82,7 @@ for k in ( 'fee_estimate_mode', # _autoset_opts ): msg('{:30} {}'.format( f'cfg.{k}:', getattr(cfg,k) )) + +msg('') +for n, k in enumerate(cfg._args, 1): + msg(f'arg{n}: {k}')